diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ffa20060..1060a1d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,19 @@ jobs: CGO_ENABLED=0 GOOS=$os go vet ./... done + # The RPC and /events contract is api/, recorded in + # api/testdata/contract.golden. Its own step so a PR that moves it without + # the golden fails by name and shows what moved, rather than as one red + # test among the suite's. + - name: API contract + run: | + if ! go test -run '^(TestContractMatchesGolden|TestEveryRequestIsOneObject|TestWireShapesAreWhatMarshalJSONWrites)$' ./api; then + go test -run '^TestContractMatchesGolden$' ./api -update > /dev/null + echo "::error::the api contract moved without api/testdata/contract.golden: run go test ./api -update and commit the diff below" + git diff -- api/testdata/contract.golden + exit 1 + fi + - name: Backend tests run: bash .github/scripts/go-test-summary.sh linux diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67d2095a3..c0d607170 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -243,7 +243,7 @@ jobs: for _ in $(seq 1 60); do sleep 1; grep -q 'native window attached' "$config/lich.log" && break; done grep -q 'native window attached' "$config/lich.log" || fail "lich did not open its first window" token=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$config/runtime.json") - curl -fsS -X POST -H 'Content-Type: application/json' -d '["window.closeAction","","background"]' \ + curl -fsS -X POST -H 'Content-Type: application/json' -d '[{"key":"window.closeAction","projectId":"","value":"background"}]' \ "http://127.0.0.1:$LICH_LISTEN_PORT/rpc/store.SetSetting?token=$token" > /dev/null || fail "storing Keep running failed" "$app/lich.exe" quit || fail "the first lich quit exited $?" for _ in $(seq 1 10); do tasklist | grep -q -e '^lich.exe' -e lich-native.exe || break; sleep 1; done diff --git a/CLAUDE.md b/CLAUDE.md index 8acccb6ab..36e7973db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,11 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here. into a session, goes through the catalogs and lands in **every** locale in the same PR (`lich-i18n` skill). English is the source; tests stay English. Error text, `CHANGELOG.md`, MCP tool descriptions and CLI help stay English by design; the What's new dialog reads a release's translations from `changelog/.md`. -- Service shapes are hand-owned in `native/lichclient`: touch a Go struct's JSON tags and its mirror there moves - in the same change. There is no codegen. +- An RPC method is declared once in `api` and exposed with `rpc.Handle` in `internal/routes`: the backend, + `lich` and `native/backend` compile against that one declaration, and `api/testdata/contract.golden` records + its shape (`go test ./api -update` after a change meant to move it). Its options are one struct, never + positional arguments. An `/events` event is declared there too, emitted with `events.Send` and read with + `backend.Decode`. There is no codegen. - A fact more than one place shows (git status, a pull request, plan quota, a session's profile or status) has one store per window, fed by one reader, and every screen reads that store. A change to the fact reaches the store as an event on `/events`, emitted by the backend once the change is true (after the respawn, not before), diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07e7ebe3b..849d750d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,8 +65,10 @@ Then: ```bash for os in linux darwin windows; do GOOS=$os go build ./... && GOOS=$os go vet ./...; done ``` -- **Touched a Go struct's JSON tags?** `native/lichclient` mirrors them by - hand. There is no codegen, so it moves in the same change. +- **Touched an RPC method or an `/events` payload?** Every method and event is + declared in `api`, shared by the backend, `lich` and the window; run `go test + ./api -update` and review the diff of `api/testdata/contract.golden`. There is + no codegen. Commit messages follow [Conventional Commits](https://www.conventionalcommits.org) (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`, `ci:`). diff --git a/api/agentplugin.go b/api/agentplugin.go new file mode 100644 index 000000000..adfcd81ff --- /dev/null +++ b/api/agentplugin.go @@ -0,0 +1,34 @@ +package api + +var ( + AgentPluginStatus = define[None, []PluginStatus]("agentplugin.Status") + AgentPluginInstall = define[PluginProviderOptions, None]("agentplugin.Install") + AgentPluginUpdate = define[PluginProviderOptions, None]("agentplugin.Update") +) + +// PluginProviderOptions names the provider whose lich plugin is installed or +// updated. +type PluginProviderOptions struct { + Provider string `json:"provider"` +} + +// PluginStatus is one provider's plugin state, reported to the window. +type PluginStatus struct { + Provider string `json:"provider"` + Name string `json:"name"` + // Available is whether the provider's CLI is on PATH at all. A provider the + // machine does not have is still listed — with nothing to install — so the + // UI can say why it is not on offer. + Available bool `json:"available"` + Installed bool `json:"installed"` + InstalledVersion string `json:"installedVersion"` + // LatestVersion is the newest release this lich is compatible with, not the + // newest the plugin has published: it is the one an install or update writes. + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` + // Compatible is false when the installed release is outside the range this + // lich speaks (PluginVersionFloor, PluginVersionCeiling). Its reports still + // land where they parse; UpdateAvailable then offers LatestVersion, a + // downgrade included. + Compatible bool `json:"compatible"` +} diff --git a/api/api.go b/api/api.go new file mode 100644 index 000000000..e9e133f21 --- /dev/null +++ b/api/api.go @@ -0,0 +1,62 @@ +// Package api is the contract between the lich backend and its clients: the +// window (native/backend), `lich` and `lich mcp`. Each RPC is declared here +// once, as a Method whose type parameters are what it takes and what it +// returns, so a client and the backend that disagree on either do not compile. +// +// On the wire a call is a JSON array holding its one options object, or empty +// for a method that takes nothing: the envelope internal/rpc has always read, +// which keeps a `lich` of another release talking to this backend. Fields are +// matched by name, never by position, so an option one side does not know is +// dropped and one it does not send is its zero value. +// +// The package is pure Go with no dependency beyond the standard library: the +// window's module imports it, and must not reach internal/. +// testdata/contract.golden records every method's shape; a change to it is a +// change to the contract, visible in review. +package api + +import ( + "fmt" + "reflect" +) + +// Method names one RPC. Req is its options object (None for a method that +// takes nothing) and Resp its result (None for one that only fails or not). +type Method[Req, Resp any] struct { + Name string +} + +// None is the request of a method that takes nothing and the result of one +// that returns only an error. +type None struct{} + +// Args is req in the wire envelope. +func (m Method[Req, Resp]) Args(req Req) []any { + if _, empty := any(req).(None); empty { + return []any{} + } + return []any{req} +} + +// Declared is one Method with its type parameters as values, for code that +// walks the whole contract (the golden test). +type Declared struct { + Name string + Request reflect.Type + Response reflect.Type +} + +var declared = map[string]Declared{} + +// Methods returns every Method this package declares. +func Methods() map[string]Declared { + return declared +} + +func define[Req, Resp any](name string) Method[Req, Resp] { + if _, dup := declared[name]; dup { + panic(fmt.Sprintf("api: %s declared twice", name)) + } + declared[name] = Declared{Name: name, Request: reflect.TypeFor[Req](), Response: reflect.TypeFor[Resp]()} + return Method[Req, Resp]{Name: name} +} diff --git a/api/api_test.go b/api/api_test.go new file mode 100644 index 000000000..c5b49f620 --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,241 @@ +package api + +import ( + "encoding/json" + "flag" + "fmt" + "maps" + "os" + "reflect" + "slices" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "rewrite testdata/contract.golden from the declared methods") + +const goldenPath = "testdata/contract.golden" + +var marshalerType = reflect.TypeFor[json.Marshaler]() + +// wireShapes are the types that encode themselves, each with the struct its +// MarshalJSON writes, which the golden shows in its place. +var wireShapes = map[reflect.Type]reflect.Type{ + reflect.TypeFor[RevertResult](): reflect.TypeFor[revertWire](), +} + +// TestContractMatchesGolden makes every change to a method's name, options or +// result, and to an event's name or payload, show up as a diff of +// testdata/contract.golden. A renamed JSON field +// compiles on both sides and still breaks a client of another release; the +// golden is where review sees it. Run `go test ./api -update` after a change +// meant to move the contract. +func TestContractMatchesGolden(t *testing.T) { + got := render(Methods(), Events()) + if *update { + if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatal(err) + } + // The repository carries no .gitattributes, so a Windows checkout may hand + // back CRLF for the golden committed with LF: the checkout's spelling, not + // drift. + if got != strings.ReplaceAll(string(want), "\r\n", "\n") { + t.Error("the contract moved; if that is meant, run `go test ./api -update` and review the diff of " + goldenPath) + } +} + +// TestEveryRequestIsOneObject keeps positional arguments out of the contract: +// a request that is a bare string or bool is matched by position, and breaks +// the release its meaning changes. +func TestEveryRequestIsOneObject(t *testing.T) { + for name, m := range Methods() { + if m.Request.Kind() != reflect.Struct { + t.Errorf("%s takes %s; a request is one struct, or None", name, m.Request) + } + } +} + +// TestWireShapesAreWhatMarshalJSONWrites keeps wireShapes honest: the golden +// shows the wire struct, so it has to be the keys MarshalJSON writes. +func TestWireShapesAreWhatMarshalJSONWrites(t *testing.T) { + for typ, wire := range wireShapes { + data, err := json.Marshal(reflect.New(typ).Elem().Interface()) + if err != nil { + t.Fatal(err) + } + var written map[string]json.RawMessage + if err := json.Unmarshal(data, &written); err != nil { + t.Fatal(err) + } + var keys []string + for field := range wire.Fields() { + if name, _, wired := jsonName(field); wired { + keys = append(keys, name) + } + } + if got := slices.Sorted(maps.Keys(written)); !slices.Equal(got, slices.Sorted(slices.Values(keys))) { + t.Errorf("%s writes %v, its wire shape %s names %v", typ, got, wire, keys) + } + } +} + +func TestArgsWrapsOneObjectAndLeavesNoneEmpty(t *testing.T) { + if got := ThemesSelect.Args(SelectThemeOptions{ID: "nord"}); !reflect.DeepEqual(got, []any{SelectThemeOptions{ID: "nord"}}) { + t.Errorf("Args(options) = %v, want the options alone", got) + } + if got := ThemesList.Args(None{}); len(got) != 0 || got == nil { + t.Errorf("Args(None) = %#v, want an empty, non-nil list (it encodes as [])", got) + } +} + +func TestDefineRefusesADuplicateName(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("a second declaration of themes.List did not panic") + } + }() + define[None, None]("themes.List") +} + +func TestDefineEventRefusesADuplicateName(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("a second declaration of session-status did not panic") + } + }() + defineEvent[None]("session-status") +} + +func TestEventFamilyNamesOneEventPerSession(t *testing.T) { + if got := TerminalExitEvents.Of("s1").Name; got != "terminal:exit:s1" { + t.Errorf("Of(s1) = %q", got) + } + if id, ok := TerminalExitEvents.ID("terminal:exit:s1"); !ok || id != "s1" { + t.Errorf("ID = %q, %v; want s1", id, ok) + } + if _, ok := TerminalExitEvents.ID("terminal:data:s1"); ok { + t.Error("another family's event read as this one's") + } +} + +// render prints each method as `name(Request) Response` and each event as +// `event name Payload`, sorted, followed by every named type they reach, each +// once. +func render(methods map[string]Declared, events map[string]DeclaredEvent) string { + r := &renderer{seen: map[reflect.Type]bool{}} + var b strings.Builder + for _, name := range slices.Sorted(maps.Keys(methods)) { + m := methods[name] + fmt.Fprintf(&b, "%s(%s) %s\n", name, r.ref(m.Request), r.ref(m.Response)) + } + b.WriteString("\n") + for _, name := range slices.Sorted(maps.Keys(events)) { + fmt.Fprintf(&b, "event %s %s\n", name, r.ref(events[name].Payload)) + } + for len(r.queue) > 0 { + next := r.queue[0] + r.queue = r.queue[1:] + b.WriteString("\n") + b.WriteString(r.define(next)) + } + return b.String() +} + +type renderer struct { + seen map[reflect.Type]bool + queue []reflect.Type +} + +// ref names t as a field or a signature shows it, queueing the named types +// of this package for their own definition. +func (r *renderer) ref(t reflect.Type) string { + if t.Name() != "" && t.PkgPath() == reflect.TypeFor[None]().PkgPath() { + if !r.seen[t] { + r.seen[t] = true + r.queue = append(r.queue, t) + } + return t.Name() + } + if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { + return "base64" + } + switch t.Kind() { + case reflect.Pointer: + return "*" + r.ref(t.Elem()) + case reflect.Slice: + return "[]" + r.ref(t.Elem()) + case reflect.Map: + return "map[" + r.ref(t.Key()) + "]" + r.ref(t.Elem()) + } + return t.String() +} + +func (r *renderer) define(t reflect.Type) string { + if t.Implements(marshalerType) { + wire, ok := wireShapes[t] + if !ok { + panic(fmt.Sprintf("api: %s encodes itself; add the struct its MarshalJSON writes to wireShapes", t.Name())) + } + return r.defineStruct(t.Name()+" (MarshalJSON)", wire) + } + if t.Kind() != reflect.Struct { + return fmt.Sprintf("%s = %s\n", t.Name(), r.ref(reflectUnderlying(t))) + } + return r.defineStruct(t.Name(), t) +} + +func (r *renderer) defineStruct(name string, t reflect.Type) string { + var b strings.Builder + fmt.Fprintf(&b, "%s {\n", name) + for field := range t.Fields() { + name, omitempty, wired := jsonName(field) + if !wired { + continue + } + if field.Anonymous && field.Tag.Get("json") == "" { + fmt.Fprintf(&b, "\t...%s\n", r.ref(field.Type)) + continue + } + mark := "" + if omitempty { + mark = ",omitempty" + } + fmt.Fprintf(&b, "\t%s%s %s\n", name, mark, r.ref(field.Type)) + } + b.WriteString("}\n") + return b.String() +} + +// reflectUnderlying is t without its name: Syntax's map type. +func reflectUnderlying(t reflect.Type) reflect.Type { + switch t.Kind() { + case reflect.Map: + return reflect.MapOf(t.Key(), t.Elem()) + case reflect.Slice: + return reflect.SliceOf(t.Elem()) + } + panic(fmt.Sprintf("api: the golden does not render named %s types (%s)", t.Kind(), t.Name())) +} + +// jsonName is the key encoding/json writes field under, and whether it +// writes the field at all. +func jsonName(field reflect.StructField) (name string, omitempty, wired bool) { + if !field.IsExported() { + return "", false, false + } + tag := field.Tag.Get("json") + if tag == "-" { + return "", false, false + } + name, opts, _ := strings.Cut(tag, ",") + if name == "" { + name = field.Name + } + return name, strings.Contains(opts, "omitempty"), true +} diff --git a/api/appupdate.go b/api/appupdate.go new file mode 100644 index 000000000..3bda755ae --- /dev/null +++ b/api/appupdate.go @@ -0,0 +1,18 @@ +package api + +var ( + AppUpdateGetStatus = define[None, AppUpdateStatus]("appupdate.Status") + AppUpdateApply = define[None, None]("appupdate.Apply") +) + +// AppUpdateStatus is lich's install/update state, reported to the window. +type AppUpdateStatus struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` + CanSelfApply bool `json:"canSelfApply"` + ReleaseURL string `json:"releaseUrl"` + // InstallCommand is the shell command the UI pastes (never auto-runs) to + // update a package-manager-owned install; empty on the self-apply platforms. + InstallCommand string `json:"installCommand"` +} diff --git a/api/drop.go b/api/drop.go new file mode 100644 index 000000000..cb6eb22ba --- /dev/null +++ b/api/drop.go @@ -0,0 +1,20 @@ +package api + +var DropAttach = define[AttachDropOptions, DropAttachment]("drop.Attach") + +// AttachDropOptions asks for a file to attach to a session: SessionID's +// checkout is Root, and a Confined session reads only what lies inside it. +type AttachDropOptions struct { + SessionID string `json:"sessionId"` + Root string `json:"root"` + Confined bool `json:"confined"` +} + +// DropAttachment is what the picker produced: the path to write at the prompt, and +// the line that goes under it when that path is a copy's (copyNotice), empty +// when the session opens the file where it lies. Both empty for a cancelled +// dialog. +type DropAttachment struct { + Path string `json:"path"` + Notice string `json:"notice"` +} diff --git a/api/eventpayloads.go b/api/eventpayloads.go new file mode 100644 index 000000000..aeb24c20b --- /dev/null +++ b/api/eventpayloads.go @@ -0,0 +1,255 @@ +package api + +// SessionStatus is the payload of SessionStatusEvent: the session whose processing +// state changed, the new state, and — on a pre-tool report alone — the tool it +// is about to run and what that tool acts on, or — on a waiting one alone — what +// it is blocked on. All three are the provider's own words, never translated +// here (docs/hooks/session-state.md tables them). +type SessionStatus struct { + ID string `json:"id"` + State string `json:"state"` + Tool string `json:"tool,omitempty"` + Detail string `json:"detail,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// SessionTitle is the payload of SessionTitleEvent: the session whose label changed +// and its new label. +type SessionTitle struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// SessionTouched is the payload of SessionTouchedEvent: the session whose files +// likely changed. +type SessionTouched struct { + ID string `json:"id"` +} + +// SessionAgent is the payload of SessionAgentEvent: the session and the provider +// CLI now live in its PTY ("" when none is). +type SessionAgent struct { + ID string `json:"id"` + Agent string `json:"agent"` +} + +type SessionConversation struct { + ID string `json:"id"` + ProviderSessionID string `json:"providerSessionId"` +} + +// SessionSandbox is the payload of SessionSandboxEvent: the session, whether its PTY +// is confined, and the home paths that sandbox skipped for being symlinks, +// relative to the home (nil for an unconfined spawn and for one that skipped +// none). +type SessionSandbox struct { + ID string `json:"id"` + Confined bool `json:"confined"` + SkippedLinks []string `json:"skippedLinks"` +} + +// SessionMCP is the payload of SessionMCPEvent: the session and the MCP servers its +// provider could reach when it was spawned. +type SessionMCP struct { + ID string `json:"id"` + Servers []string `json:"servers"` +} + +// SessionDuplicate is the payload of SessionDuplicateEvent. +type SessionDuplicate struct { + ID string `json:"id"` + HeldBy string `json:"heldBy"` +} + +// SessionCwd is the payload of SessionCwdEvent. +type SessionCwd struct { + ID string `json:"id"` + Cwd string `json:"cwd"` + Host string `json:"host"` +} + +// SessionLimit is the payload of SessionLimitEvent. +type SessionLimit struct { + ID string `json:"id"` + Window string `json:"window"` + ResetsAt int64 `json:"resetsAt"` +} + +type SessionProfile struct { + ID string `json:"id"` + // Profile is the slug, "" for Personal. + Profile string `json:"profile"` +} + +// SessionTodo is the payload of SessionTodoEvent. Both counts are the whole list the +// agent last wrote, so a card can decide for itself whether a list is worth +// drawing (a finished one is not, and neither is a list of one). +type SessionTodo struct { + ID string `json:"id"` + Done int `json:"done"` + Total int `json:"total"` +} + +type PluginIncompatible struct { + ID string `json:"id"` + Version string `json:"version"` +} + +// SessionExit is the payload of TerminalExitEvents: the status the session's PTY +// child exited with. Code is absent when the child left none — killed by a +// signal, or a wait that failed — so the window can say the session ended +// without reporting a clean exit nobody observed. +type SessionExit struct { + Code *int `json:"code,omitempty"` +} + +// SessionFocus is the payload of SessionFocusEvent. +type SessionFocus struct { + ID string `json:"id"` +} + +// SessionInbox is the payload of SessionInboxEvent. +type SessionInbox struct { + ID string `json:"id"` + Count int `json:"count"` +} + +// SessionSchedule is the payload of SessionScheduleEvent: the session whose mark +// changed, when its prompt is now due, and the prompt. At is 0 and Prompt empty +// when nothing is waiting anymore. +type SessionSchedule struct { + ID string `json:"id"` + At int64 `json:"at"` + Prompt string `json:"prompt,omitempty"` +} + +// SessionRelay is the payload of SessionRelayEvent: the session whose mark changed, +// the label at the other end, which way the request runs, and the ticket the +// two ends share. Peer is empty when the other end is not a session at all — +// the CLI run from a script or a shell — which the card words its own way. +// +// The ticket rides along because it is otherwise written down in exactly one +// place: the message typed at the target's prompt. An agent whose context was +// compacted past that message has no way back to it, and neither had the person +// watching — the window knew a request was open and could not say which one. +// Empty when the mark is being cleared, along with the direction. +type SessionRelay struct { + ID string `json:"id"` + Peer string `json:"peer"` + Direction string `json:"direction"` + Ticket string `json:"ticket"` +} + +// RelayStalled is the payload of RelayStalledEvent: who asked ("" when the +// request came from the command line rather than a session), and the session +// that has whatever was produced. +type RelayStalled struct { + ID string `json:"id"` + TargetID string `json:"targetId"` + Target string `json:"target"` +} + +// SessionClosed is the payload of SessionClosedEvent: which session left, the project +// it left, and which of that project's sessions is active now — the window has +// no say in the choice, because the row that records it is already written. +type SessionClosed struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + ActiveID string `json:"activeId"` +} + +// SessionsFiled is SessionsFiledEvent's payload. Folder is "" when the cards were taken +// out of the one they were in. +type SessionsFiled struct { + ProjectID string `json:"projectId"` + IDs []string `json:"ids"` + Folder string `json:"folder"` +} + +// SessionsColored is SessionsColoredEvent's payload. Color is "" when the cards went +// back to the theme. +type SessionsColored struct { + ProjectID string `json:"projectId"` + IDs []string `json:"ids"` + Color string `json:"color"` +} + +// ProjectAdded is the payload of ProjectAddedEvent: the identity as stored. +type ProjectAdded struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` +} + +// ProjectClosed is the payload of ProjectClosedEvent. +type ProjectClosed struct { + ID string `json:"id"` +} + +// ProjectsReordered is the payload of ProjectsReorderedEvent: every open +// project, in the order that was stored. +type ProjectsReordered struct { + IDs []string `json:"ids"` +} + +// ProfileLogin is one step of a login. Output carries what the CLI printed, with +// URL set when that text holds the sign-in address. Authenticating says the CLI +// is through with the browser and lich is reading whether the login took. The +// last event has Done set: +// LoggedIn is what the CLI's login status answered afterwards (`claude auth +// status`, `codex login status`), Error why the login failed or was cancelled, +// and PluginError why the lich plugin did not install. +type ProfileLogin struct { + Profile string `json:"profile"` + Output string `json:"output,omitempty"` + URL string `json:"url,omitempty"` + Authenticating bool `json:"authenticating,omitempty"` + Done bool `json:"done,omitempty"` + LoggedIn bool `json:"loggedIn,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` + Error string `json:"error,omitempty"` + PluginError string `json:"pluginError,omitempty"` +} + +// ProfileChanged is ProfileChangedEvent's payload: Provider is the provider id +// ("claude", "kiro"), Profile the profile as it now is, without its login state. +type ProfileChanged struct { + Provider string `json:"provider"` + Profile Profile `json:"profile"` +} + +// PluginProfiles is what one pass did: the release it installed and the +// profiles where the install failed, in the CLI's own words. Name is the +// provider's display name. +type PluginProfiles struct { + Provider string `json:"provider"` + Name string `json:"name"` + Version string `json:"version"` + Failures []PluginProfileFailure `json:"failures"` +} + +// PluginProfileFailure is one profile the plugin could not be installed into. +type PluginProfileFailure struct { + Profile string `json:"profile"` + Name string `json:"name"` + Error string `json:"error"` +} + +// AppUpdateProgress is one step of Apply: bytes received of total during the download +// (total is -1 when the asset came without a Content-Length), then the phase +// that has no percentage: "installer", where lich hands over to the Windows +// installer and closes. +type AppUpdateProgress struct { + Phase string `json:"phase"` + Received int64 `json:"received"` + Total int64 `json:"total"` +} + +// ScheduleForfeited is a scheduled prompt lost with the session it was parked +// on, which was deleted for good: the row was the only copy of what the user +// wrote. +type ScheduleForfeited struct { + Label string `json:"label"` + At int64 `json:"at"` + Prompt string `json:"prompt"` +} diff --git a/api/events.go b/api/events.go new file mode 100644 index 000000000..3e5f89817 --- /dev/null +++ b/api/events.go @@ -0,0 +1,282 @@ +package api + +import ( + "fmt" + "reflect" + "strings" +) + +// Event names one message of the /events stream; T is its payload (None for +// one that carries nothing). +type Event[T any] struct { + Name string +} + +// EventFamily is a set of events named by a prefix and a session id, one per +// session: "terminal:exit:". Terminal I/O is named this way so each +// terminal subscribes only to its own stream instead of filtering a global +// broadcast. Every other session event is global and carries the id in its +// payload: its consumers outlive any one card, and a per-session name can only +// reach a subscriber that exists when it is emitted. +type EventFamily[T any] struct { + Prefix string +} + +// Of is the family's event for session id. +func (f EventFamily[T]) Of(id string) Event[T] { + return Event[T]{Name: f.Prefix + id} +} + +// ID is the session an event of the family is about, false for an event of +// another name. +func (f EventFamily[T]) ID(name string) (string, bool) { + return strings.CutPrefix(name, f.Prefix) +} + +// DeclaredEvent is one Event or EventFamily with its payload as a value, for +// code that walks the whole contract (the golden test). Name ends in "" +// for a family. +type DeclaredEvent struct { + Name string + Payload reflect.Type +} + +var declaredEvents = map[string]DeclaredEvent{} + +// Events returns every Event and EventFamily this package declares. +func Events() map[string]DeclaredEvent { + return declaredEvents +} + +func declareEvent[T any](name string) { + if _, dup := declaredEvents[name]; dup { + panic(fmt.Sprintf("api: event %s declared twice", name)) + } + declaredEvents[name] = DeclaredEvent{Name: name, Payload: reflect.TypeFor[T]()} +} + +func defineEvent[T any](name string) Event[T] { + declareEvent[T](name) + return Event[T]{Name: name} +} + +func defineEventFamily[T any](prefix string) EventFamily[T] { + declareEvent[T](prefix + "") + return EventFamily[T]{Prefix: prefix} +} + +var ( + // SessionStatusEvent carries a session's processing state ({id, state, tool, + // detail, reason} — "busy"/"done"/"waiting"/"idle"/"compacting", plus the tool + // a pre-tool report names and what a waiting one is blocked on), reported by + // the lich hooks running inside the PTY (see transport.hook and + // docs/hooks/session-state.md). "interrupted" is the one value lich raises + // itself, for a turn the user stopped at the PTY. The window keeps it in a + // store keyed by id (statusStore, native/cmd/lich- native/status.go). + SessionStatusEvent = defineEvent[SessionStatus]("session-status") + + // SessionUsageEvent carries a session's context-window usage ({id, percent, + // tokens}), emitted after a turn ends. Global like the other session events. + SessionUsageEvent = defineEvent[SessionUsage]("session-usage") + + // SessionTitleEvent carries a session's new name ({id, label}), so every card + // takes it without a reload: the auto-applied ai-title, and a rename the store + // has written, whoever asked (the window, `lich rename` or the MCP tool). One + // event for both because the payload is the same pair and the window's answer + // to both is the same: relabel that card. + SessionTitleEvent = defineEvent[SessionTitle]("session-title") + + // SessionTouchedEvent carries the id of a session that likely changed files on + // disk, nudging an immediate git-status refresh ahead of the steady poll. + SessionTouchedEvent = defineEvent[SessionTouched]("session-touched") + + // SessionAgentEvent carries which provider CLI is live inside a session's PTY + // ({id, agent}) — reported through the session-start hook by whichever + // provider ships it, so a shell card shows that provider's mark while its CLI + // runs in it. An empty agent clears the mark; every PTY spawn emits that clear + // so a respawned session never wears a dead agent's icon. + SessionAgentEvent = defineEvent[SessionAgent]("session-agent") + + // SessionConversationEvent carries the provider conversation a session's row + // now names ({id, providerSessionId}, "" once it names none), emitted after + // the row is written: the window offers that conversation back when the + // session's process exits. + SessionConversationEvent = defineEvent[SessionConversation]("session-conversation") + + // SessionSandboxEvent carries whether a session's PTY runs confined ({id, + // confined, skippedLinks}), emitted by every spawn. The card marks a confined + // session, and the answer is the spawn's own — it takes the provider's rung, + // the checkout and a per-session override to reach, so the window is told + // rather than asked to work it out again. skippedLinks is what that sandbox + // left out of the private home for being a symlink, which is the one absence a + // session has no other way of learning about. Both persisted with the row + // (store.Session's Sandbox and SandboxSkippedLinks), which is what a reopened + // window hydrates from. + SessionSandboxEvent = defineEvent[SessionSandbox]("session-sandbox") + + // SessionMCPEvent carries the MCP servers a session's provider could reach at + // its spawn ({id, servers}), emitted by every spawn. The card divides a tool + // name against them — two harnesses spell an MCP tool with a single underscore + // between the server and the tool, which nothing in the string divides + // (docs/hooks/session-state.md). Persisted with the row too (store.Session's + // MCPServers), which is what a reopened window hydrates from: the PTY outlives + // the window, so there is no second spawn to report it again. + SessionMCPEvent = defineEvent[SessionMCP]("session-mcp") + + // SessionDuplicateEvent carries a session that just reported a conversation + // another open card already runs ({id, heldBy}): a resume typed in the + // provider's own TUI, which lich cannot stop, only point out. Emitted once the + // report is stored, so the window can offer to switch to heldBy. + SessionDuplicateEvent = defineEvent[SessionDuplicate]("session-duplicate") + + // SessionCwdEvent carries a session's live working directory ({id, cwd, + // host}), emitted once with the directory the PTY starts in and again whenever + // the child process moves (the user runs `cd`). A non-empty host names a + // foreground process the directory cannot be read through (see shellHosts) and + // comes with an empty cwd: the session is somewhere, but not somewhere this + // machine can name. Global like the other session events. + SessionCwdEvent = defineEvent[SessionCwd]("session-cwd") + + // SessionLimitEvent carries a turn a plan's usage limit ended ({id, window, + // resetsAt}). Nothing clears it; the window reads plan quota again when it + // arrives (planQuota, native/cmd/lich-native/footer_quota.go). + SessionLimitEvent = defineEvent[SessionLimit]("session-limit") + + // SessionProfileEvent carries the profile a session moved to: Continue in, or + // a resume that found its conversation in another profile. + SessionProfileEvent = defineEvent[SessionProfile]("session-profile") + + // SessionTodoEvent carries how far a session got through the task list its + // agent wrote for itself ({id, done, total}), emitted whenever that pair + // changes. Global like the other session events. + // + // The pair is what the agent last wrote, not what is true: an agent that + // abandons a list without closing its items leaves the card reporting the + // count it was abandoned at, until it writes another one. + SessionTodoEvent = defineEvent[SessionTodo]("session-todo") + + // PluginIncompatibleEvent carries a session whose hooks report from a plugin + // release outside the range this lich speaks ({id, version}). The window + // answers it by asking for the plugin status again, which is where the fix is + // offered: an install moved by the harness's own marketplace shows up here + // first. + PluginIncompatibleEvent = defineEvent[PluginIncompatible]("plugin-incompatible") + + // TerminalDataEvents carries base64-encoded PTY output. Output is + // base64-encoded because raw PTY bytes may split a multi-byte UTF-8 sequence + // mid-read, which the JSON event bridge would otherwise corrupt. + TerminalDataEvents = defineEventFamily[string]("terminal:data:") + + // TerminalExitEvents is emitted once when a session's shell process exits, + // carrying the status it exited with (see SessionExit). + TerminalExitEvents = defineEventFamily[SessionExit]("terminal:exit:") + + // SessionFocusEvent asks the window to open a session's card, the way a click + // on it does. Global like the other session events: the card it names may live + // in a project whose tab is not the one on screen. + SessionFocusEvent = defineEvent[SessionFocus]("session-focus") + + // SessionInboxEvent carries how many uncollected results a session has + // waiting, so its card can say so — the nudge tells the agent, and this tells + // the person. Emitted whenever a sender's inbox changes size; zero clears the + // mark. Global for the same reason the relay event is. + SessionInboxEvent = defineEvent[SessionInbox]("session-inbox") + + // SessionScheduleEvent is emitted when a session's scheduled prompt changes + // without the window having written it: delivered, or parked and dropped by + // lich itself around a usage limit (resume.go). Payload: SessionSchedule. + SessionScheduleEvent = defineEvent[SessionSchedule]("session-schedule") + + // SessionRelayEvent carries which session is waiting on which, so the sidebar + // can say it. Global rather than per-session because its consumer — one store + // keyed by id — outlives any one card, exactly as the status event's does. + SessionRelayEvent = defineEvent[SessionRelay]("session-relay") + + // RelayStalledEvent carries a request whose target ended its turn without + // answering through lich. The window turns it into a toast that opens the + // target's card, because the answer — if there is one — is on that screen and + // the person who asked has no other way to know where it went. + RelayStalledEvent = defineEvent[RelayStalled]("session-relay-stalled") + + // SessionOpenedEvent carries a session opened outside the window, so the card + // appears without a reload. Global, like every other session event: its + // consumer is the workspace state, which outlives any one card. + SessionOpenedEvent = defineEvent[OpenedSession]("session-opened") + + // SessionClosedEvent carries a session closed outside the window, so the card + // goes away without a reload. Its payload is SessionClosed. + SessionClosedEvent = defineEvent[SessionClosed]("session-closed") + + // SessionsFiledEvent carries sessions whose folder changed outside the window, + // so their cards move into or out of the folder's block without a reload. One + // event for a card filed and for a folder renamed, because the window's answer + // to both is the same: set these cards' folder. + SessionsFiledEvent = defineEvent[SessionsFiled]("sessions-filed") + + // SessionsColoredEvent carries sessions painted outside the window, so their + // cards take the colour without a reload. + SessionsColoredEvent = defineEvent[SessionsColored]("sessions-colored") + + // ProjectOpenedEvent carries a project opened outside the window — an agent + // naming a directory rather than a project already on screen — so its tab + // appears without a reload. Its payload is the whole Project, sessions + // included: a reopened project brings back the cards it was closed with, and + // the session this open is about to announce lands in a tab the window has to + // be holding already. + ProjectOpenedEvent = defineEvent[Project]("project-opened") + + // ProjectAddedEvent is emitted once AddProject has committed: a project opened + // new, reopened from the history, or reopened at the directory it moved to — + // the same id under another path is how a relocate reaches the store. Payload: + // ProjectAdded. It is not project-opened, which an agent's open announces with + // the whole project and which the web workspace adds a tab from; this one only + // says the row changed, and the window reads it back. + ProjectAddedEvent = defineEvent[ProjectAdded]("project-added") + + // ProjectClosedEvent is emitted once CloseProject has marked a project closed, + // whoever asked: the window's own tab, an agent, the CLI. Payload: + // ProjectClosed. + ProjectClosedEvent = defineEvent[ProjectClosed]("project-closed") + + // ProjectsReorderedEvent is emitted once ReorderProjects has written the tab + // order. Payload: ProjectsReordered. + ProjectsReorderedEvent = defineEvent[ProjectsReordered]("projects-reordered") + + // ScheduleForfeitedEvent is emitted when a scheduled prompt was lost with the + // session it was parked on, so the person who parked it is told rather than + // left to find out at a time that never comes. Payload: ScheduleForfeited. + ScheduleForfeitedEvent = defineEvent[ScheduleForfeited]("session-schedule-forfeited") + + // ClaudeProfileLoginEvent is the event a login reports its progress on. + ClaudeProfileLoginEvent = defineEvent[ProfileLogin]("claude-profile-login") + + // CodexProfileLoginEvent is the event a login reports its progress on. + CodexProfileLoginEvent = defineEvent[ProfileLogin]("codex-profile-login") + + // ProfileChangedEvent is the event a profile's update emits once it is + // written: its name, color or image changed. + ProfileChangedEvent = defineEvent[ProfileChanged]("profile-changed") + + // PluginProfilesEvent is the event a pass over a provider's profiles reports + // on, once it has installed into at least one. + PluginProfilesEvent = defineEvent[PluginProfiles]("lich-plugin-profiles") + + // AppUpdateProgressEvent is the hub event Apply's phases ride on, for the + // toast. + AppUpdateProgressEvent = defineEvent[AppUpdateProgress]("appupdate-progress") + + // ThemeSelectionEvent is pushed on /events with a ThemeSelection once a change + // to it is stored. + ThemeSelectionEvent = defineEvent[ThemeSelection]("theme-selection") + + // PullRequestChangedEvent is what a pull request write through lich reports + // once GitHub took it, so every window's pull request store reads it again + // instead of the screen that made the change telling the others. + PullRequestChangedEvent = defineEvent[PRChanged]("pull-request-changed") + + // WindowCloseEvent asks the native window to close, lich quitting. + WindowCloseEvent = defineEvent[None]("window-close") + + // WindowRaiseEvent asks the native window to come forward. + WindowRaiseEvent = defineEvent[None]("window-raise") +) diff --git a/api/patchnotes.go b/api/patchnotes.go new file mode 100644 index 000000000..ef85edf8e --- /dev/null +++ b/api/patchnotes.go @@ -0,0 +1,30 @@ +package api + +var PatchNotesCurrent = define[PatchNotesOptions, PatchNotes]("patchnotes.Current") + +// PatchNotesOptions names the interface language to read the release in. +type PatchNotesOptions struct { + Locale string `json:"locale"` +} + +// PatchNotes is a release's changelog section, ready for the window to render. +type PatchNotes struct { + Version string `json:"version"` + Highlights []PatchNotesHighlight `json:"highlights"` + Groups []PatchNotesGroup `json:"groups"` +} + +// PatchNotesGroup is one "### Added / Changed / Fixed" block of a release's notes. +type PatchNotesGroup struct { + Label string `json:"label"` + // Items keep their markdown bold/code markers; the window renders them. + Items []string `json:"items"` +} + +// PatchNotesHighlight is one GitHub alert block ("> [!IMPORTANT]") written between a +// release's heading and its first group. Kind is the alert type, lowercased. +type PatchNotesHighlight struct { + Kind string `json:"kind"` + // Text keeps its markdown bold/code markers; the window renders them. + Text string `json:"text"` +} diff --git a/api/profiles.go b/api/profiles.go new file mode 100644 index 000000000..a2007f8fb --- /dev/null +++ b/api/profiles.go @@ -0,0 +1,161 @@ +package api + +// ProfileMethods are the calls every provider's profile service answers. +type ProfileMethods struct { + Profiles Method[None, []ProfileInfo] + CreateProfile Method[CreateProfileOptions, Profile] + UpdateProfile Method[UpdateProfileOptions, Profile] + DeleteProfile Method[ProfileSlugOptions, None] + LoginCommand Method[ProfileSlugOptions, string] +} + +// ProfileLoginMethods run a provider's login from lich, where its CLI has one +// that is not a terminal card's: Claude Code's and Codex's. +type ProfileLoginMethods struct { + Login Method[ProfileSlugOptions, None] + CancelLogin Method[ProfileSlugOptions, None] +} + +// ProfilePluginMethods carry plugins between profiles: Claude Code's and +// Codex's. +type ProfilePluginMethods struct { + Plugins Method[ProfileSlugOptions, []ProfilePlugin] + InstallPlugins Method[InstallPluginsOptions, []ProfilePluginResult] +} + +var ( + ClaudeProfiles = profileMethods("claudeprofile") + ClaudeProfileLogin = profileLoginMethods("claudeprofile") + ClaudeProfilePlugins = profilePluginMethods("claudeprofile") + ClaudeProfileLoginCode = define[LoginCodeOptions, None]("claudeprofile.LoginCode") + CodexProfiles = profileMethods("codexprofile") + CodexProfileLogin = profileLoginMethods("codexprofile") + CodexProfilePlugins = profilePluginMethods("codexprofile") + OMPProfiles = profileMethods("ompprofile") + // XDGProfiles are the profile services of the providers whose logins a + // variable moves (internal/xdgprofile), by provider id. + XDGProfiles = map[string]ProfileMethods{ + "opencode": profileMethods("opencodeprofile"), + "cursor": profileMethods("cursorprofile"), + "kiro": profileMethods("kiroprofile"), + } +) + +func profileMethods(service string) ProfileMethods { + return ProfileMethods{ + Profiles: define[None, []ProfileInfo](service + ".Profiles"), + CreateProfile: define[CreateProfileOptions, Profile](service + ".CreateProfile"), + UpdateProfile: define[UpdateProfileOptions, Profile](service + ".UpdateProfile"), + DeleteProfile: define[ProfileSlugOptions, None](service + ".DeleteProfile"), + LoginCommand: define[ProfileSlugOptions, string](service + ".LoginCommand"), + } +} + +func profileLoginMethods(service string) ProfileLoginMethods { + return ProfileLoginMethods{ + Login: define[ProfileSlugOptions, None](service + ".Login"), + CancelLogin: define[ProfileSlugOptions, None](service + ".CancelLogin"), + } +} + +func profilePluginMethods(service string) ProfilePluginMethods { + return ProfilePluginMethods{ + Plugins: define[ProfileSlugOptions, []ProfilePlugin](service + ".Plugins"), + InstallPlugins: define[InstallPluginsOptions, []ProfilePluginResult](service + ".InstallPlugins"), + } +} + +// CreateProfileOptions names a new profile. +type CreateProfileOptions struct { + Name string `json:"name"` +} + +// UpdateProfileOptions changes profile Slug as Update says. +type UpdateProfileOptions struct { + Slug string `json:"slug"` + Update ProfileUpdate `json:"update"` +} + +// ProfileSlugOptions names a profile by its slug. +type ProfileSlugOptions struct { + Slug string `json:"slug"` +} + +// InstallPluginsOptions installs Keys (plugin@marketplace) into profile Slug. +type InstallPluginsOptions struct { + Slug string `json:"slug"` + Keys []string `json:"keys"` +} + +// LoginCodeOptions hands a running login of profile Slug the code its +// sign-in page showed. +type LoginCodeOptions struct { + Slug string `json:"slug"` + Code string `json:"code"` +} + +// Profile is one login lich knows about. +type Profile struct { + Slug string `json:"slug"` + Name string `json:"name"` + Dir string `json:"dir"` + Personal bool `json:"personal"` + // Copied names the shared entries (a settings file, say) this profile holds + // as a copy, so edits made in one account no longer reach the other. + Copied []string `json:"copied,omitempty"` + // Color is the card color the profile is shown in, "" for the one the + // window derives from the slug. + Color string `json:"color,omitempty"` + // Image is the profile's picture as a PNG, avatarEdge square. + Image []byte `json:"image,omitempty"` +} + +// ProfileUpdate is a change to how a profile is shown. A field left nil keeps what the +// profile has; Name keeps it when empty. +type ProfileUpdate struct { + Name string `json:"name,omitempty"` + // Color is a card color's name; "" goes back to the color the window + // derives from the slug. The window draws a name it does not know the same + // way, so it is stored as given. + Color *string `json:"color,omitempty"` + // Image is a file to make the profile's image from; "" removes the image. + Image *string `json:"image,omitempty"` +} + +// ProfileInfo is a profile with its login state, as the profile services of +// every provider list it; a field a provider has no reading for stays empty. +type ProfileInfo struct { + Profile + LoggedIn bool `json:"loggedIn"` + Email string `json:"email,omitempty"` + OrgName string `json:"orgName,omitempty"` + // Plan is the subscription the login is on: Claude Code's own name ("pro", + // "max", "team", …), or Codex's from its plan reading. + Plan string `json:"plan,omitempty"` + // AuthMethod is how a Claude Code profile authenticates: "claude.ai", + // "console", or "none" when nobody is logged in. + AuthMethod string `json:"authMethod,omitempty"` + // Plugins counts a Claude Code or Codex profile's plugins. + Plugins int `json:"plugins"` + // Providers are the providers an oh-my-pi profile holds an OAuth login for. + Providers []string `json:"providers,omitempty"` + // StatusError is why the CLI's status gave no answer, in its own words; + // LoggedIn is then false for want of one. + StatusError string `json:"statusError,omitempty"` +} + +// ProfilePlugin is one plugin a profile has installed at user scope. +type ProfilePlugin struct { + Key string `json:"key"` + Name string `json:"name"` + Marketplace string `json:"marketplace"` + Version string `json:"version,omitempty"` +} + +// ProfilePluginResult is one plugin's install, with Claude Code's own words when it +// refused: an organization's policy, a marketplace the profile cannot reach. +type ProfilePluginResult struct { + Key string `json:"key"` + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} diff --git a/api/project.go b/api/project.go new file mode 100644 index 000000000..8fb129b22 --- /dev/null +++ b/api/project.go @@ -0,0 +1,699 @@ +package api + +import "encoding/json" + +var ( + ProjectOpen = define[None, *OpenedProject]("project.Open") + ProjectHome = define[None, *OpenedProject]("project.Home") + ProjectRelocate = define[ProjectIDOptions, *OpenedProject]("project.Relocate") + ProjectExists = define[PathOptions, bool]("project.Exists") + ProjectMissing = define[PathsOptions, []string]("project.Missing") + ProjectPickFile = define[PickFileOptions, string]("project.PickFile") + ProjectPickSaveFile = define[PickSaveFileOptions, string]("project.PickSaveFile") + ProjectDiff = define[PathOptions, DiffStats]("project.Diff") + ProjectDiffText = define[PathOptions, string]("project.DiffText") + ProjectBranchesOf = define[PathsOptions, map[string]string]("project.BranchesOf") + ProjectBaseStatus = define[PathOptions, *BaseStatus]("project.BaseStatus") + ProjectBaseFreshness = define[PathBranchOptions, BaseFreshness]("project.BaseFreshness") + ProjectBranchRules = define[PathBranchOptions, *BranchRules]("project.BranchRules") + ProjectCommitIdentity = define[PathOptions, CommitIdentity]("project.CommitIdentity") + ProjectTree = define[PathOptions, FileListing]("project.Tree") + ProjectSearch = define[SearchOptions, SearchResult]("project.Search") + ProjectReadFile = define[CheckoutFileOptions, string]("project.ReadFile") + ProjectReadFileAt = define[ReadFileAtOptions, string]("project.ReadFileAt") + ProjectFileLines = define[FileLinesOptions, []string]("project.FileLines") + ProjectDiscardFile = define[CheckoutFileOptions, None]("project.DiscardFile") + ProjectRevertLines = define[RevertLinesOptions, RevertResult]("project.RevertLines") + ProjectRestoreLines = define[RestoreLinesOptions, None]("project.RestoreLines") + ProjectCarryUncommitted = define[CarryUncommittedOptions, None]("project.CarryUncommitted") + ProjectListBranches = define[PathOptions, Branches]("project.ListBranches") + ProjectListCheckouts = define[PathOptions, []Worktree]("project.ListCheckouts") + ProjectCreateWorktree = define[CreateWorktreeOptions, *Worktree]("project.CreateWorktree") + ProjectCreateWorktreeFromPR = define[CreateWorktreeFromPROptions, *Worktree]("project.CreateWorktreeFromPR") + ProjectRemoveWorktree = define[RemoveWorktreeOptions, None]("project.RemoveWorktree") + ProjectWorktreeDirty = define[PathOptions, bool]("project.WorktreeDirty") + ProjectWorktreeAdopted = define[PathOptions, bool]("project.WorktreeAdopted") + ProjectGitHubAccounts = define[None, []string]("project.GitHubAccounts") + ProjectIssue = define[NumberOptions, Issue]("project.Issue") + ProjectPullRequest = define[PathOptions, *PullRequest]("project.PullRequest") + ProjectListPullRequests = define[ListPullRequestsOptions, []PRSummary]("project.ListPullRequests") + ProjectPullRequestDetail = define[NumberOptions, *PRDetail]("project.PullRequestDetail") + ProjectPullRequestConversation = define[NumberOptions, *PRConversation]("project.PullRequestConversation") + ProjectPullRequestDiff = define[NumberOptions, string]("project.PullRequestDiff") + ProjectPullRequestMergeBase = define[NumberOptions, string]("project.PullRequestMergeBase") + ProjectPullRequestConflicts = define[PRConflictsOptions, []string]("project.PullRequestConflicts") + ProjectCreatePullRequest = define[PathOptions, None]("project.CreatePullRequest") + ProjectMergePullRequest = define[MergePullRequestOptions, None]("project.MergePullRequest") + ProjectEditPullRequestTitle = define[PRTitleOptions, None]("project.EditPullRequestTitle") + ProjectEditPullRequestBody = define[PRBodyOptions, None]("project.EditPullRequestBody") + ProjectCommentOnPullRequest = define[PRBodyOptions, None]("project.CommentOnPullRequest") + ProjectEditPullRequestComment = define[PRCommentBodyOptions, None]("project.EditPullRequestComment") + ProjectDeletePullRequestComment = define[PRCommentOptions, None]("project.DeletePullRequestComment") + ProjectSubmitReview = define[SubmitReviewOptions, None]("project.SubmitReview") + ProjectEditReviewBody = define[PRReviewBodyOptions, None]("project.EditReviewBody") + ProjectEditReviewComment = define[PRCommentBodyOptions, None]("project.EditReviewComment") + ProjectDeleteReviewComment = define[PRCommentOptions, None]("project.DeleteReviewComment") + ProjectReplyToReviewThread = define[PRCommentBodyOptions, None]("project.ReplyToReviewThread") + ProjectResolveReviewThread = define[ResolveThreadOptions, None]("project.ResolveReviewThread") +) + +// PathOptions names a checkout, or a directory, by its path. +type PathOptions struct { + Path string `json:"path"` +} + +// PathsOptions names several paths. +type PathsOptions struct { + Paths []string `json:"paths"` +} + +// PickFileOptions titles the file chooser. +type PickFileOptions struct { + Title string `json:"title"` +} + +// PickSaveFileOptions titles the save dialog and names the file it offers. +type PickSaveFileOptions struct { + Title string `json:"title"` + DefaultName string `json:"defaultName"` +} + +// PathBranchOptions names a branch of the checkout at Path. +type PathBranchOptions struct { + Path string `json:"path"` + Branch string `json:"branch"` +} + +// SearchOptions searches the checkout at Path for Query. +type SearchOptions struct { + Path string `json:"path"` + Query string `json:"query"` +} + +// CheckoutFileOptions names file Rel of the checkout at Path. +type CheckoutFileOptions struct { + Path string `json:"path"` + Rel string `json:"rel"` +} + +// ReadFileAtOptions names file Rel of the checkout at Path as it was at Ref. +type ReadFileAtOptions struct { + Path string `json:"path"` + Rel string `json:"rel"` + Ref string `json:"ref"` +} + +// FileLinesOptions reads lines From to To of file Rel, at Ref ("" for the +// working tree). +type FileLinesOptions struct { + Path string `json:"path"` + Rel string `json:"rel"` + Ref string `json:"ref"` + From int `json:"from"` + To int `json:"to"` +} + +// RevertLinesOptions puts Lines of file Rel back to HEAD. +type RevertLinesOptions struct { + Path string `json:"path"` + Rel string `json:"rel"` + Lines []RevertLine `json:"lines"` +} + +// RestoreLinesOptions undoes the revert Undo describes. +type RestoreLinesOptions struct { + Path string `json:"path"` + Rel string `json:"rel"` + Undo RevertResult `json:"undo"` +} + +// CarryUncommittedOptions carries the uncommitted changes of checkout Src to +// checkout Dst. +type CarryUncommittedOptions struct { + Src string `json:"src"` + Dst string `json:"dst"` +} + +// CreateWorktreeOptions creates worktree Name of the project at ProjectPath, +// branched from Base (a remote branch when BaseIsRemote). +type CreateWorktreeOptions struct { + ProjectPath string `json:"projectPath"` + ProjectID string `json:"projectId"` + Name string `json:"name"` + Base string `json:"base"` + BaseIsRemote bool `json:"baseIsRemote"` +} + +// CreateWorktreeFromPROptions creates a worktree on pull request Number's +// branch. +type CreateWorktreeFromPROptions struct { + ProjectPath string `json:"projectPath"` + ProjectID string `json:"projectId"` + Number int `json:"number"` +} + +// RemoveWorktreeOptions removes the worktree at WorktreePath. AdoptedAck is +// the user's answer to removing one lich did not create. +type RemoveWorktreeOptions struct { + ProjectPath string `json:"projectPath"` + WorktreePath string `json:"worktreePath"` + Force bool `json:"force"` + AdoptedAck bool `json:"adoptedAck"` +} + +// NumberOptions names issue or pull request Number of the checkout at Path's +// repository. +type NumberOptions struct { + Path string `json:"path"` + Number int `json:"number"` +} + +// ListPullRequestsOptions lists the pull requests in State ("open", "closed", +// "merged", "all"). +type ListPullRequestsOptions struct { + Path string `json:"path"` + State string `json:"state"` +} + +// PRConflictsOptions asks which files of pull request Number conflict with +// Base. +type PRConflictsOptions struct { + Path string `json:"path"` + Number int `json:"number"` + Base string `json:"base"` + PRURL string `json:"prUrl"` +} + +// MergePullRequestOptions merges pull request Number by Method ("merge", +// "squash", "rebase"); Admin bypasses the branch's rules. +type MergePullRequestOptions struct { + Path string `json:"path"` + Number int `json:"number"` + Method string `json:"method"` + Subject string `json:"subject"` + Body string `json:"body"` + Admin bool `json:"admin"` +} + +// PRTitleOptions retitles pull request Number. +type PRTitleOptions struct { + Path string `json:"path"` + Number int `json:"number"` + Title string `json:"title"` +} + +// PRBodyOptions is a text for pull request Number: its description, or a +// comment on it. +type PRBodyOptions struct { + Path string `json:"path"` + Number int `json:"number"` + Body string `json:"body"` +} + +// PRCommentOptions names comment CommentID of pull request Number. +type PRCommentOptions struct { + Path string `json:"path"` + Number int `json:"number"` + CommentID int64 `json:"commentId"` +} + +// PRCommentBodyOptions is a text for comment CommentID of pull request Number: +// its new body, or a reply to it. +type PRCommentBodyOptions struct { + Path string `json:"path"` + Number int `json:"number"` + CommentID int64 `json:"commentId"` + Body string `json:"body"` +} + +// PRReviewBodyOptions rewrites the body of review ReviewID. +type PRReviewBodyOptions struct { + Path string `json:"path"` + Number int `json:"number"` + ReviewID int64 `json:"reviewId"` + Body string `json:"body"` +} + +// SubmitReviewOptions submits a review of pull request Number: Event is the +// verdict, Comments the line comments, and Head the commit they were written +// against. +type SubmitReviewOptions struct { + Path string `json:"path"` + Number int `json:"number"` + Event string `json:"event"` + Body string `json:"body"` + Head string `json:"head"` + Comments []ReviewComment `json:"comments"` +} + +// ResolveThreadOptions resolves review thread ThreadID, or opens it again. +type ResolveThreadOptions struct { + Path string `json:"path"` + ThreadID string `json:"threadId"` + Resolved bool `json:"resolved"` +} + +// BaseStatus is where a checkout stands against the branch it will merge into: +// how far that branch has moved underneath it, and which files a merge would +// collide on right now. It is what turns "the merge button refused" into +// something a parallel worktree knows before anyone clicks it. +type BaseStatus struct { + Base string `json:"base"` // the branch merged into, "origin/main" form + Behind int `json:"behind"` // commits the base has that this checkout does not + Conflicts []string `json:"conflicts"` +} + +// Issue is the little of a GitHub issue the New worktree dialog needs: what to +// name the branch after, and what to tell the session it opens. +type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` +} + +// BranchRules is what a repository's rulesets say about merging into one branch. GitHub's +// pull request fields do not carry it: `mergeStateStatus` answers whether this +// merge can happen, never which methods the branch would accept, so a Merge +// menu built without this offers buttons the ruleset refuses — and gh's +// refusal arrives after the click, as one flat sentence (gherror.go). +// +// The endpoint answers for a branch, not a pull request, which is why this is +// its own read: every pull request onto the same base shares one answer. +type BranchRules struct { + // AllowedMergeMethods is squash|merge|rebase, in GitHub's own spelling. + // Empty means the branch has no rule about it and all three stand — which + // is also what a repository with no rulesets at all reports. + AllowedMergeMethods []string `json:"allowedMergeMethods"` + // CommitPatterns are the rules that read a commit's own text. Empty is the + // common case and means only "no rule of this kind", never "nothing blocks". + CommitPatterns []CommitPattern `json:"commitPatterns"` + // ViewerCanBypass is whether this account administers the repository, and so + // may merge past the rules above. It is the one answer here that must fail + // *closed*: an unreadable one hides an action rather than offering one that + // cannot work, because a bypass offered to an account that has none reads as + // a broken button, not as a permission it never had. + ViewerCanBypass bool `json:"viewerCanBypass"` +} + +// CommitPattern is one ruleset rule that tests the text of a commit rather than +// the state of a pull request. It earns its own field because it is the one +// class of rule nothing on a pull request hints at: the checks pass, the review +// lands, no conflict exists, and GitHub still answers BLOCKED — because the +// message the merge *would* write breaks a pattern nobody can see from here. +type CommitPattern struct { + // Target is the part the rule reads, already in the words the screen uses: + // "message", "author email", "committer email". + Target string `json:"target"` + // Operator is GitHub's own: regex | starts_with | ends_with | contains. + Operator string `json:"operator"` + Pattern string `json:"pattern"` + // Negate flips the test — the pattern is then what the text must *not* be. + Negate bool `json:"negate"` +} + +// ThreadTally is how many of a pull request's review threads are still open. +type ThreadTally struct { + Open int `json:"open"` + Total int `json:"total"` +} + +// BaseFreshness is where a local branch stands against its upstream, read +// right after fetching it: what the new-worktree dialog needs to start a +// worktree from the newer of the two. Upstream is "" for a branch that tracks +// nothing on a remote, and every other field is then zero. +type BaseFreshness struct { + Upstream string `json:"upstream"` // "origin/main" form + Ahead int `json:"ahead"` + Behind int `json:"behind"` + // FetchError is why the fetch failed, "" when it did not. The counts are + // then against whatever an earlier fetch left. + FetchError string `json:"fetchError,omitempty"` +} + +// FileListing is one listing of a directory's files: the paths, plus the two +// things the Files panel has to say out loud about how they were gathered. A +// tree that silently stops short, or silently leaves a directory out, reads as +// a folder with fewer files in it than it has. +type FileListing struct { + Files []string `json:"files"` + // Cut reports a listing that stopped at walkLimit. Only a plain-folder walk + // can set it; git's own listing is never capped. + Cut bool `json:"cut"` + // Hidden names the walkIgnore directories this folder actually had, sorted + // and deduped: the panel names them, so somebody keeping source under a + // build/ or vendor/ reads why it is missing instead of guessing. + Hidden []string `json:"hidden"` +} + +// OpenedProject identifies an opened project directory. +type OpenedProject struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` +} + +// PullRequest identifies the open GitHub pull request for a work tree's current +// branch, as reported by the gh CLI. State is one of gh's OPEN, CLOSED, MERGED; +// only OPEN reaches the window (see parsePullRequest). +type PullRequest struct { + Number int `json:"number"` + URL string `json:"url"` + State string `json:"state"` +} + +// DiffStats is what one status read says about a work tree: its uncommitted +// changes, and the two things they sit on. Head is the commit — the window +// watches it to notice a commit the way it watches Files/Added/Deleted to +// notice an edit — and Branch is the branch, carried here because git answers +// both in the same call the counts come out of. +type DiffStats struct { + Files int `json:"files"` + Added int `json:"added"` + Deleted int `json:"deleted"` + Head string `json:"head"` + Branch string `json:"branch"` +} + +// PRDetail is the full view of a branch's open pull request — richer than the +// footer badge's PullRequest — driving the dock's Pulls panel: the title, body, +// CI rollup and mergeability gate the merge affordance. gh's state does not +// travel with it: a detail exists only when the PR is open (parsePRDetail). +type PRDetail struct { + Number int `json:"number"` + URL string `json:"url"` + Title string `json:"title"` + Body string `json:"body"` + // Author is the login of whoever opened it, or their display name when gh + // reports no login. GitHub refuses a review request addressed to them, which + // is what keeps them out of the picker. + Author string `json:"author"` + // AuthorLogin is the same account as Author, minus the fallback to a display + // name — the value a commit's login is compared against. Author cannot serve: + // a name matches no login, so a PR whose author gh reports without one would + // read as every commit landing under the wrong account. + AuthorLogin string `json:"authorLogin"` + // State is gh's OPEN | CLOSED | MERGED. Only a number-addressed lookup can + // return a non-OPEN one; the branch lookup still gates them out. + State string `json:"state"` + IsDraft bool `json:"isDraft"` + Mergeable string `json:"mergeable"` // gh: MERGEABLE | CONFLICTING | UNKNOWN + // MergeStateStatus is GitHub's own answer to "can this merge right now" — + // CLEAN | UNSTABLE | BLOCKED | BEHIND | DIRTY | DRAFT | HAS_HOOKS | UNKNOWN. + // Mergeable alone cannot gate the button: it says nothing about a required + // review or a required check, and it reads UNKNOWN while GitHub recomputes + // after a push. + MergeStateStatus string `json:"mergeStateStatus"` + // ReviewDecision is gh's aggregate verdict — APPROVED | CHANGES_REQUESTED | + // REVIEW_REQUIRED — and "" where the repository requires no review. Who + // reviewed is a different field (latestReviews) this does not carry. + ReviewDecision string `json:"reviewDecision"` + // Reviewers is who was asked and who answered, in one list (prreviewers.go). + Reviewers []PRReviewer `json:"reviewers"` + BaseRefName string `json:"baseRefName"` + HeadRefName string `json:"headRefName"` + ChangedFiles int `json:"changedFiles"` + Checks ChecksRollup `json:"checks"` + CheckRuns []CheckItem `json:"checkRuns"` + Commits []PRCommit `json:"commits"` + // IsCrossRepository marks a head branch that lives on a fork. + IsCrossRepository bool `json:"isCrossRepository"` + // MaintainerCanModify is GitHub's "allow edits by maintainers" on a fork PR, + // and the whole of what decides whether a session can be opened on one: with + // it a push back to the fork's branch is authorised, without it there is + // nowhere for an agent's commits to go (CreateWorktreeFromPR). It is false on + // a same-repository PR, where nothing needs the permission. + MaintainerCanModify bool `json:"maintainerCanModify"` +} + +// PRCommit is one commit the pull request would land, split the way git itself +// splits a message: the subject line, then the body (empty for a one-liner). +// +// Login and Name are kept apart because only one of them is evidence: Name is +// free text, Login is the account GitHub resolved the author's email to against +// every account's verified addresses — an answer lich cannot compute, and one +// whose "" means the email belongs to nobody rather than "unknown". +type PRCommit struct { + OID string `json:"oid"` + Headline string `json:"headline"` + Body string `json:"body"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + Date string `json:"date"` // gh's ISO committedDate +} + +// PRSummary is one row of the repository's open pull requests — what the list +// column needs to render and rank a PR before anything is selected. The full +// view behind a row is PRDetail, fetched per PR. +type PRSummary struct { + Number int `json:"number"` + Title string `json:"title"` + Author string `json:"author"` // login, or the display name when gh reports no login + State string `json:"state"` // gh: OPEN | CLOSED | MERGED + IsDraft bool `json:"isDraft"` + // ReviewDecision is gh's APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED, + // and "" for a repository that requires no review. + ReviewDecision string `json:"reviewDecision"` + HeadRefName string `json:"headRefName"` + // IsCrossRepository marks a PR whose head branch lives on a fork. lich can + // check such a branch out, but the commits would have nowhere to push, so + // the session flow refuses it (CreateWorktreeFromPR) and the button reads + // as blocked rather than failing on click. + IsCrossRepository bool `json:"isCrossRepository"` + UpdatedAt string `json:"updatedAt"` // gh's ISO timestamp + Checks ChecksRollup `json:"checks"` + // Threads is counted only on a CHANGES_REQUESTED row (prlistthreads.go); + // every other row carries a zero tally. + Threads ThreadTally `json:"threads"` +} + +// CommitIdentity is the name and email the next commit in a checkout would be +// authored with — the fact the project's gh account (ghaccount.go) does not +// carry, since that one only governs what lich asks gh. Local says the value +// comes from the checkout's own config rather than the global one. +// +// The zero value is a legitimate answer, not a failure: a checkout with no +// identity configured is one of the states worth showing. +type CommitIdentity struct { + Name string `json:"name"` + Email string `json:"email"` + Local bool `json:"local"` +} + +// RevertLine names one changed line of the diff the panel drew. Side "old" is a +// deletion, numbered in HEAD; "new" is an addition, numbered in the working +// tree. Text is the line without its +/- prefix, and is what proves the number +// still points at the same line. +type RevertLine struct { + Side string `json:"side"` + Line int `json:"line"` + Text string `json:"text"` +} + +// PRReviewer is one entry of a pull request's review roster: somebody who was +// asked and has not answered yet, or somebody who has. +type PRReviewer struct { + // Login is the account's login, or a team's slug — which is not what gh takes + // to add or remove one (that is org/slug), so a team is shown and not edited. + Login string `json:"login"` + IsTeam bool `json:"isTeam"` + // State is "" while the review is still owed, else gh's APPROVED | + // CHANGES_REQUESTED | COMMENTED | DISMISSED. + State string `json:"state"` +} + +// PRConversation is everything said about a pull request: the reviews and their +// verdicts, the comments left on the pull request itself, and the threads +// anchored to a file and line. The Pulls screen renders the anchored ones inside +// the diff and the rest in its Conversation tab. +type PRConversation struct { + // HeadRefOID is the commit a new review comment is filed against. GitHub + // defaults to the latest commit, but naming it means a review submitted + // seconds after a push cannot silently land on lines nobody read. + HeadRefOID string `json:"headRefOid"` + Reviews []PRReview `json:"reviews"` + // Comments are the pull request's own comments — the ones with no file and + // no line, which GitHub files as issue comments. + Comments []PRComment `json:"comments"` + Threads []PRReviewThread `json:"threads"` +} + +// PRReview is one submitted review: its verdict, and whatever was written in the +// summary box above the line comments. +type PRReview struct { + // ID is the GraphQL node id, which is what dismissing the review takes. + ID string `json:"id"` + // DatabaseID is the REST id, the address its summary is rewritten at. + DatabaseID int64 `json:"databaseId"` + Author string `json:"author"` + // State is gh's APPROVED | CHANGES_REQUESTED | COMMENTED | DISMISSED. + State string `json:"state"` + Body string `json:"body"` + Date string `json:"date"` + CanEdit bool `json:"canEdit"` +} + +// PRComment is one comment on the pull request itself. +type PRComment struct { + // ID is the REST database id, the address an edit or a delete takes. + ID int64 `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Date string `json:"date"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} + +// PRReviewThread is one conversation anchored to a line of the diff. +type PRReviewThread struct { + // ID is the GraphQL node id, which is what resolving a thread takes. It is + // not the number a reply is addressed to — that is a comment's own ID. + ID string `json:"id"` + Path string `json:"path"` + // Line is the anchor in the file the thread's side names, and StartLine the + // first line of a multi-line thread (0 when it covers one line). A thread + // GitHub could not re-anchor keeps its original line and is marked outdated, + // which is what sends it to the Conversation tab instead of the diff. + Line int `json:"line"` + StartLine int `json:"startLine"` + // Side is RIGHT for a thread on the new file, LEFT for one on a deleted + // line — the two sides the diff renders in one document. + Side string `json:"side"` + IsResolved bool `json:"isResolved"` + IsOutdated bool `json:"isOutdated"` + Comments []PRThreadComment `json:"comments"` +} + +// PRThreadComment is one message inside a thread. +type PRThreadComment struct { + // ID is the REST database id — the address a reply is posted to. GraphQL + // node ids do not work there, which is why both identifiers travel. + ID int64 `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + Date string `json:"date"` + // DiffHunk is GitHub's own snippet of the lines the thread is about. The + // diff already has them where the thread is anchored; this is what an + // outdated thread has left. + DiffHunk string `json:"diffHunk"` + // CanEdit is GitHub's own answer to whether the account reading may + // rewrite the comment, which is what the Edit button stands on. + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} + +// PRChanged names the checkout the write ran in and the pull request it +// addressed; Number is 0 where the write addresses a thread or a review rather +// than a number (the checkout's repository is then all that is known). +type PRChanged struct { + Path string `json:"path"` + Number int `json:"number"` +} + +// SearchMatch is one line of one file that contains the query. +type SearchMatch struct { + Path string `json:"path"` + // Line is 1-based, the same numbering the preview's gutter shows. + Line int `json:"line"` + // Text is the line with its indentation dropped, cut to an excerpt around + // the first occurrence when the line is long. + Text string `json:"text"` +} + +// SearchResult is one search over a checkout's files. +type SearchResult struct { + Matches []SearchMatch `json:"matches"` + // Cut reports a search that stopped at maxSearchMatches. + Cut bool `json:"cut"` + // TooLarge counts the listed files skipped for being above maxReadFileSize. + // The tree shows them, so an answer that left them out says so; a binary + // is skipped without a count, since nobody searches one for text. + TooLarge int `json:"tooLarge"` +} + +// ChecksRollup collapses gh's statusCheckRollup array into the counts the panel +// shows. Total is every check, so "all green" is Passed == Total && Total > 0. +type ChecksRollup struct { + Passed int `json:"passed"` + Failed int `json:"failed"` + Pending int `json:"pending"` + Total int `json:"total"` +} + +// CheckItem is one check, flattened from gh's two rollup shapes into what the +// Checks tab lists. State is this package's own passed|failed|pending, the same +// verdict ChecksRollup counts. The timestamps are gh's ISO strings, left for the +// window to turn into a duration. +type CheckItem struct { + Name string `json:"name"` + State string `json:"state"` + Description string `json:"description"` // workflow name, or the status context's own line + URL string `json:"url"` // where to read the run; "" when gh reports none + StartedAt string `json:"startedAt"` + CompletedAt string `json:"completedAt"` + // Required is GitHub's own answer to "does this check gate the merge", the + // same verdict its checks list labels Required. + Required bool `json:"required"` +} + +// ReviewComment is one line comment of a review being submitted. Line is the +// last line it covers and StartLine the first (0 for a single-line comment) — +// GitHub's own convention, kept here so a selection crosses unchanged. +type ReviewComment struct { + Path string `json:"path"` + Line int `json:"line"` + StartLine int `json:"startLine"` + // Side is RIGHT (the new file) or LEFT (a deleted line). Empty means RIGHT, + // which is where all but a deletion comment goes. + Side string `json:"side"` + Body string `json:"body"` +} + +// Worktree is a git worktree checkout: the branch it holds and its path. +type Worktree struct { + Name string `json:"name"` + Path string `json:"path"` + // Whether CreateWorktree checked out a branch that already existed instead + // of starting one at the base it was given — the one case where the base + // the caller picked decided nothing. Always false on a listed worktree. + Reused bool `json:"reused,omitempty"` +} + +// Branches groups everything the base-branch picker offers: local and remote +// branch names plus already-existing worktrees, so a closed-but-kept worktree +// can be resumed instead of recreated. +type Branches struct { + Local []string `json:"local"` + Remote []string `json:"remote"` // "origin/main" form + Worktrees []Worktree `json:"worktrees"` +} + +// RevertResult is what undoing a revert needs: the patch reversed onto the +// working tree, and the one reversed onto the index ("" when the index did not +// hold the change and was left alone). +type RevertResult struct { + Patch string + IndexPatch string +} + +// revertWire is RevertResult as it crosses the RPC: the patches are git's raw +// bytes, which need not be UTF-8 (a Latin-1 file), so they travel as base64 +// rather than as JSON text that would turn each such byte into U+FFFD. +type revertWire struct { + Patch []byte `json:"patch"` + IndexPatch []byte `json:"indexPatch"` +} + +func (r RevertResult) MarshalJSON() ([]byte, error) { + return json.Marshal(revertWire{Patch: []byte(r.Patch), IndexPatch: []byte(r.IndexPatch)}) +} + +func (r *RevertResult) UnmarshalJSON(data []byte) error { + var wire revertWire + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + r.Patch, r.IndexPatch = string(wire.Patch), string(wire.IndexPatch) + return nil +} diff --git a/api/providers.go b/api/providers.go new file mode 100644 index 000000000..22f57a7bd --- /dev/null +++ b/api/providers.go @@ -0,0 +1,61 @@ +package api + +var ( + ProvidersDetect = define[None, []DetectedProvider]("providers.Detect") + ProvidersOnPath = define[ProviderIDOptions, []ProviderBinary]("providers.OnPath") + ProvidersRefreshPath = define[None, None]("providers.RefreshPath") + ProvidersVerify = define[BinaryOptions, BinaryCheck]("providers.Verify") + ProvidersVersion = define[BinaryOptions, string]("providers.Version") +) + +// The Status of a BinaryCheck that passed, and of one with nothing to check; +// every other value is a failure. +const ( + CheckOK = "ok" + CheckEmpty = "empty" +) + +// Failed reports a check to show as a problem. +func (b BinaryCheck) Failed() bool { return b.Status != CheckOK && b.Status != CheckEmpty } + +// ProviderIDOptions names a provider by its id ("claude", "codex"). +type ProviderIDOptions struct { + ID string `json:"id"` +} + +// BinaryOptions names an executable as a session's spawn would resolve it. +type BinaryOptions struct { + Bin string `json:"bin"` +} + +// DetectedProvider reports a provider and whether a binary a session could spawn was +// found — on PATH, or at the path the user configured. +// Binary is the executable name a session spawns (DefaultBinary), which the +// settings screen needs even when nothing was found: a provider id is not its +// command — Antigravity's is `agy`. +// Docs is carried on every entry, installed or not: the row that has somewhere +// to send the user is exactly the one that found nothing. +// Source is SourcePath or SourceSetting, empty when nothing was found. +type DetectedProvider struct { + ID string `json:"id"` + Name string `json:"name"` + Binary string `json:"binary"` + Installed bool `json:"installed"` + Path string `json:"path"` + Source string `json:"source"` + Docs string `json:"docs"` +} + +// ProviderBinary is one executable a provider could be started from, and the version it +// reports, empty when it printed none lich could read. +type ProviderBinary struct { + Path string `json:"path"` + Version string `json:"version"` +} + +// BinaryCheck is what a configured binary resolves to. Path is the absolute +// executable a session would spawn, empty for every status but CheckOK. +type BinaryCheck struct { + Path string `json:"path"` + Status string `json:"status"` +} diff --git a/api/quota.go b/api/quota.go new file mode 100644 index 000000000..4fce843c0 --- /dev/null +++ b/api/quota.go @@ -0,0 +1,63 @@ +package api + +var ( + QuotaPlans = define[QuotaPlansOptions, []QuotaPlan]("quota.Plans") + QuotaProfilePlan = define[ProfilePlanOptions, QuotaPlan]("quota.ProfilePlan") + QuotaCodexProfilePlan = define[ProfilePlanOptions, QuotaPlan]("quota.CodexProfilePlan") +) + +// QuotaPlansOptions names the session whose account's plans are read; "" +// reads the machine's own login. +type QuotaPlansOptions struct { + SessionID string `json:"sessionId"` +} + +// ProfilePlanOptions names the profile whose plan is read, "personal" for the +// machine's own. +type ProfilePlanOptions struct { + Profile string `json:"profile"` +} + +// QuotaPlan is one provider's quota reading. Provider is a providers.Registry id, so +// the window resolves the icon and the login command it already knows. +// +// Account names the login the windows were read against — the whole point of +// the custom-binary path being that a session can spend an account lich's own +// environment does not name. It is empty whenever the provider will not say +// which, which is a reading without a name and never an error: a gauge that +// cannot name its account is exactly what lich drew before this field existed. +// +// NoAccount carries why it is empty, for the one absence a reader can act on. +// Left empty, an unnamed account is drawn the same whether the provider names +// nobody or the session's own login is one lich may not ask about, and nothing +// on the gauge told the two apart. +type QuotaPlan struct { + Provider string `json:"provider"` + Name string `json:"name"` + Plan string `json:"plan,omitempty"` + Account string `json:"account,omitempty"` + NoAccount string `json:"noAccount,omitempty"` + Windows []QuotaWindow `json:"windows,omitempty"` + Status string `json:"status"` +} + +// QuotaWindow is one metered window of a plan: how much of it is spent, how long it +// runs, and when it starts over. +// +// Label is the window's name as the provider frames it ("Session", "Weekly"), or +// the display name of the model a scoped window belongs to. Seconds is its +// length, so the window can print the metered window it is looking at; 0 when the +// provider does not report one. ResetsAt is RFC 3339, empty when unreported. +// Active is the provider's own verdict on which window is the binding one, when +// it reports one at all. LockedReason is non-empty only when the provider says +// this window cannot be spent past regardless of its percentage. Ahead is +// lich's own reading of the rate rather than the provider's — see pace.go. +type QuotaWindow struct { + Label string `json:"label"` + Seconds int `json:"seconds"` + Percent int `json:"percent"` + ResetsAt string `json:"resetsAt,omitempty"` + Active bool `json:"active,omitempty"` + LockedReason string `json:"lockedReason,omitempty"` + Ahead bool `json:"ahead,omitempty"` +} diff --git a/api/relay.go b/api/relay.go new file mode 100644 index 000000000..cf1850bba --- /dev/null +++ b/api/relay.go @@ -0,0 +1,171 @@ +package api + +var ( + RelayPeers = define[PeersOptions, []Peer]("relay.Peers") + RelaySelf = define[SelfOptions, Peer]("relay.Self") + RelaySend = define[SendOptions, SendResult]("relay.Send") + RelaySendPrivate = define[SendOptions, SendResult]("relay.SendPrivate") + RelaySendSubagent = define[SendOptions, SendResult]("relay.SendSubagent") + RelayWait = define[WaitOptions, SendResult]("relay.Wait") + RelayCollect = define[CollectOptions, CollectedResults]("relay.Collect") + RelayCollectNow = define[CollectNowOptions, CollectedResults]("relay.CollectNow") + RelayReply = define[ReplyOptions, None]("relay.Reply") + RelayInsert = define[InsertTextOptions, InsertedText]("relay.Insert") + RelayFocus = define[FocusSessionOptions, FocusedSession]("relay.Focus") + RelayAnnounceMerge = define[AnnounceMergeOptions, None]("relay.AnnounceMerge") + RelaySubmit = define[SubmitOptions, None]("relay.Submit") +) + +// AnnounceMergeOptions is a pull request merged through lich: the checkout its +// branch was in and what the notice tells the agents working there. +type AnnounceMergeOptions struct { + Checkout string `json:"checkout"` + Number int `json:"number"` + Title string `json:"title"` + Branch string `json:"branch"` + Base string `json:"base"` +} + +// SubmitOptions is a message typed into a session and submitted with Enter. +type SubmitOptions struct { + SessionID string `json:"sessionId"` + Message string `json:"message"` +} + +// CollectOptions is one Collect call, an object for the reason SendOptions is +// one. +type CollectOptions struct { + From string `json:"from"` + WaitSeconds int `json:"waitSeconds"` +} + +// CollectedResults is what a drain returns: every outcome that was waiting for the +// caller, oldest first, and the labels of the sessions still owing one. +type CollectedResults struct { + Results []SendResult `json:"results"` + Open []string `json:"open"` +} + +// CollectNowOptions is one CollectNow call, an object for the reason +// SendOptions is one. +type CollectNowOptions struct { + From string `json:"from"` +} + +// FocusSessionOptions is one Focus call, an object for the reason SendOptions is one. +type FocusSessionOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` +} + +// FocusedSession says which session's card was brought up, so a caller that named it +// loosely can tell the person which one it was. +type FocusedSession struct { + ID string `json:"id"` + Label string `json:"label"` + Project string `json:"project"` +} + +// InsertTextOptions is one Insert call, an object for the reason SendOptions is +// one. +type InsertTextOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` + Text string `json:"text"` + WaitSeconds int `json:"waitSeconds"` +} + +// InsertedText says where the text went, so an editor can tell the person which +// session took it when the target was chosen for them. +type InsertedText struct { + ID string `json:"id"` + Label string `json:"label"` + Project string `json:"project"` + Kind string `json:"kind"` + // Bytes is what was pasted, after control characters were stripped (sanitize). + Bytes int `json:"bytes"` +} + +// PeersOptions is one Peers call, an object for the reason SendOptions is one. +type PeersOptions struct { + From string `json:"from"` +} + +// Peer is one session a caller may address: the label it is addressed by, the +// name it answers to in Claude Code's peer roster, the project it belongs to +// (labels are unique within a project, not across them), what is running in it, +// and what that session last reported it was doing. +// +// Both names are published because both reach this session and an agent sees +// them in different places — the label on the card, the roster name in +// `/list-agents` and in what a mention writes at a prompt. Showing one and +// accepting the other is what made an agent treat a single session as two. +// +// State is stateBusy, stateWaiting or stateDone, and empty when the session has +// reported nothing — which is a provider whose plugin does not report state as +// much as a session that has not had a turn yet. Empty is "not known", never +// "idle": guessing the second is how a caller ends up sending work into a +// session that cannot take it. +type Peer struct { + Label string `json:"label"` + Name string `json:"name"` + Project string `json:"project"` + Kind string `json:"kind"` + State string `json:"state"` + // ID is the session's lich id, the LICH_SESSION_ID its process carries, + // so a reader holding a recorded id can find its card. Last, because + // fields are appended to this shape, never inserted. + ID string `json:"id"` + // Path is the directory the session runs in, the worktree's for a worktree + // session, and ProjectPath the root of the project it belongs to. They are + // what an editor matches its open folder against to find the session beside + // it; the name of a project says nothing about where it is. + Path string `json:"path"` + ProjectPath string `json:"projectPath"` +} + +// ReplyOptions is one Reply call, an object for the reason SendOptions is one. +// From is the session replying. +type ReplyOptions struct { + From string `json:"from"` + Ticket string `json:"ticket"` + Answer string `json:"answer"` +} + +// SelfOptions is one Self call, an object for the reason SendOptions is one. +type SelfOptions struct { + From string `json:"from"` +} + +// SendOptions is one Send, SendPrivate or SendSubagent call. It travels as a +// single JSON object rather than as positional arguments because a package +// manager replaces the lich binary under a running backend: a field a newer +// lich adds is dropped by an older backend, and one an older lich does not send +// is its zero value, where a positional list refuses both on its count. +type SendOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` + Prompt string `json:"prompt"` + WaitSeconds int `json:"waitSeconds"` +} + +// SendResult is what a caller gets back from Send or Wait. Answer is empty unless +// Status is StatusAnswered. +type SendResult struct { + Ticket string `json:"ticket"` + Target string `json:"target"` + Status string `json:"status"` + Answer string `json:"answer"` + // Private is set on the outcome of a ticket sent with SendPrivate, which + // only that ticket collects: no note will announce it. + Private bool `json:"private,omitempty"` +} + +// WaitOptions is one Wait call, an object for the reason SendOptions is one. +type WaitOptions struct { + Ticket string `json:"ticket"` + WaitSeconds int `json:"waitSeconds"` +} diff --git a/api/spawn.go b/api/spawn.go new file mode 100644 index 000000000..ab8b17161 --- /dev/null +++ b/api/spawn.go @@ -0,0 +1,285 @@ +package api + +var ( + SpawnOpen = define[OpenSessionOptions, OpenedSession]("spawn.Open") + SpawnOpenSubagent = define[OpenSubagentOptions, OpenedSession]("spawn.OpenSubagent") + SpawnClose = define[CloseSessionOptions, ClosedSession]("spawn.Close") + SpawnControl = define[ControlSessionOptions, ControlledSession]("spawn.Control") + SpawnAsk = define[AskSessionOptions, SessionAnswer]("spawn.Ask") + SpawnWorktrees = define[WorktreesOptions, []WorktreeCheckout]("spawn.Worktrees") + SpawnFolders = define[FoldersOptions, []SessionFolder]("spawn.Folders") + SpawnFile = define[FileSessionOptions, FiledSession]("spawn.File") + SpawnRenameFolder = define[RenameFolderOptions, RenamedFolder]("spawn.RenameFolder") + SpawnColorFolder = define[ColorFolderOptions, ColoredFolder]("spawn.ColorFolder") + SpawnRename = define[RenameSessionOptions, RenamedSession]("spawn.Rename") +) + +// OpenSessionOptions is one Open call. It travels as a single JSON object rather than +// as positional arguments because a package manager replaces the lich binary +// under a running backend: a newer CLI's option the backend does not know is +// dropped, and one an older CLI does not send is its zero value, where a +// positional list refuses both on its count. +// +// From is the session the caller runs in, empty for the command line run +// outside one. It decides two defaults: the project the session lands in and the +// provider it runs, both taken from the caller unless named. A caller with no +// session of its own must name the project — there is nothing to inherit. It is +// also recorded on the row as the new session's origin, which outlives the +// request that created it: the card says where it came from for as long as it +// exists. +// +// Project is a project already on screen, by name, or a directory path — and +// a path is the only way to reach a project the window is not holding, which is +// what opening one from the command line means. See ensureProject. +// +// Worktree, when given, is the branch name of a new git worktree created off +// Base (the project's current branch when Base is empty); the session is rooted +// there and labelled after it, exactly as the window's own worktree flow does. A +// Base without a Worktree has nothing to branch, and is refused rather than ignored. +// +// Model, when given, is the model the provider is spawned on, in that provider's +// own spelling. It is recorded on the row so every later spawn repeats it. +// Effort, when given, is the reasoning effort, passed and recorded the same way. +// Ultracode turns Claude Code's ultracode on, at whatever effort the session +// runs; it is recorded the same way, and refused for every other provider. +// +// Folder, when given, is the folder the session is filed under from its first +// frame: it is written with the row, so the card arrives in the folder's block +// rather than among its checkout's cards and then jumps. +type OpenSessionOptions struct { + From string `json:"from"` + Project string `json:"project"` + Kind string `json:"kind"` + Worktree string `json:"worktree"` + Base string `json:"base"` + Model string `json:"model"` + Effort string `json:"effort"` + Folder string `json:"folder"` + Ultracode bool `json:"ultracode"` +} + +// OpenSubagentOptions is one OpenSubagent call, an object for the reason +// OpenOptions is one. Its fields mean what Open's do; there is no project or +// folder, both of which are the caller's. +type OpenSubagentOptions struct { + From string `json:"from"` + Kind string `json:"kind"` + Worktree string `json:"worktree"` + Base string `json:"base"` + Model string `json:"model"` + Effort string `json:"effort"` + Ultracode bool `json:"ultracode"` +} + +// OpenedSession is one opened session: what the caller is told, and what the window is +// handed to draw the card. Both read the same struct because both need the same +// facts — a divergence between them is a card that does not match the session +// the agent was given. +// +// Name is the second name the session answers to, empty for a Claude Code one, +// which names itself (relay.RosterNameOf); Label is the name on its card. Both +// address it (see internal/relay). Path is the worktree +// the session lives in, empty for a session in the project's own directory — +// the store's own spelling. NextSeq is the project's label counter after this +// session took its number. OriginSessionID and OriginLabel name the session that +// asked for this one — empty when the caller was not a session at all. +// Confined is whether the session opened inside the sandbox. Nobody is at this +// end to be asked, so it is the rung's own answer (store.SandboxDefault), and +// saying it is what keeps a caller from discovering the empty home and the +// read-only machine by running into them. Folder is the folder it was filed +// under, "" for none. +type OpenedSession struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Project string `json:"project"` + Label string `json:"label"` + Name string `json:"name"` + Kind string `json:"kind"` + Path string `json:"path"` + NextSeq int `json:"nextSeq"` + OriginSessionID string `json:"originSessionId"` + OriginLabel string `json:"originLabel"` + Confined bool `json:"confined"` + Folder string `json:"folder"` +} + +// CloseSessionOptions is one Close call, an object for the reason OpenOptions is one: +// an option a newer lich adds is dropped by an older backend, and one an older +// lich does not send is its zero value. +type CloseSessionOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` + Worktree string `json:"worktree"` + Force bool `json:"force"` +} + +// ClosedSession is what a caller is told about the session it closed. +type ClosedSession struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Project string `json:"project"` + Label string `json:"label"` + // Worktree is the checkout the session lived in, empty for a session in the + // project's own directory. + Worktree string `json:"worktree"` + // Kept is the answer given to the worktree question: the checkout was left on + // disk rather than removed. Meaningless unless the close had that question to + // answer — a session sharing its checkout, or living in the project's own + // directory, reports false while leaving everything where it was. + // + // Nothing here reports the park, because every close but a removal parks and + // a field that is true on all of them tells a caller nothing. + Kept bool `json:"kept"` + // Removed is whether the checkout was deleted. + Removed bool `json:"removed"` +} + +// ControlSessionOptions is one Control call, an object for the reason CloseOptions +// is one. +type ControlSessionOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` + Action string `json:"action"` + Value string `json:"value"` + Args string `json:"args"` +} + +// ControlledSession is what a caller is told about a command it gave a session. Value +// is the prompt, model, effort or slash command's name the action carried, and +// CommandID the id the session's mod acks it under. +type ControlledSession struct { + ID string `json:"id"` + Project string `json:"project"` + Label string `json:"label"` + Action string `json:"action"` + Value string `json:"value,omitempty"` + CommandID string `json:"command_id"` + State string `json:"state"` +} + +// AskSessionOptions is one Ask call, an object for the reason CloseOptions is one. +type AskSessionOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` + Question string `json:"question"` +} + +// SessionAnswer is a session's answer to a side question. +type SessionAnswer struct { + ID string `json:"id"` + Project string `json:"project"` + Label string `json:"label"` + Answer string `json:"answer"` +} + +// WorktreesOptions is one Worktrees call, an object for the reason CloseOptions +// is one. +type WorktreesOptions struct { + From string `json:"from"` + Project string `json:"project"` +} + +// WorktreeCheckout is one of a project's git worktrees, as a caller outside the window +// needs to see it: what it is called, where it is, whether anything in it is +// uncommitted, and which sessions are open in it. +// +// Sessions is what makes the list actionable rather than informational. A +// checkout with none is one nobody is working in; the last session in one is the +// thing whose closing decides whether the checkout stays (see Close). +type WorktreeCheckout struct { + Name string `json:"name"` + Path string `json:"path"` + Dirty bool `json:"dirty"` + Sessions []string `json:"sessions"` +} + +// FoldersOptions is one Folders call, an object for the reason CloseOptions is +// one. +type FoldersOptions struct { + From string `json:"from"` + Project string `json:"project"` +} + +// SessionFolder is one of a project's folders as a caller outside the window sees it: +// its name, which is its whole identity (store.SetSessionFolder), and the +// sessions filed under it, by label. +type SessionFolder struct { + Name string `json:"name"` + Sessions []string `json:"sessions"` +} + +// FileSessionOptions is one File call, an object for the reason CloseOptions is one. +type FileSessionOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` + Folder string `json:"folder"` +} + +// FiledSession is what a caller is told about the session it filed. Previous is the +// folder it left, "" when it was in none. +type FiledSession struct { + ID string `json:"id"` + Project string `json:"project"` + Label string `json:"label"` + Folder string `json:"folder"` + Previous string `json:"previous"` +} + +// RenameFolderOptions is one RenameFolder call, an object for the reason +// CloseOptions is one. Folder is the name to change and To the new one. +type RenameFolderOptions struct { + From string `json:"from"` + Project string `json:"project"` + Folder string `json:"folder"` + To string `json:"to"` +} + +// RenamedFolder is what a caller is told about a folder it renamed or took apart: +// every session that moved, which is the part a merge into an existing folder +// would otherwise hide. +type RenamedFolder struct { + Project string `json:"project"` + From string `json:"from"` + To string `json:"to"` + Sessions []string `json:"sessions"` +} + +// ColorFolderOptions is one ColorFolder call, an object for the reason +// CloseOptions is one. +type ColorFolderOptions struct { + From string `json:"from"` + Project string `json:"project"` + Folder string `json:"folder"` + Color string `json:"color"` +} + +// ColoredFolder is what a caller is told about a folder it painted: the sessions that +// took the colour, by label. +type ColoredFolder struct { + Project string `json:"project"` + Folder string `json:"folder"` + Color string `json:"color"` + Sessions []string `json:"sessions"` +} + +// RenameSessionOptions is one Rename call, an object for the reason CloseOptions is +// one. +type RenameSessionOptions struct { + From string `json:"from"` + Target string `json:"target"` + Project string `json:"project"` + Label string `json:"label"` +} + +// RenamedSession is what a caller is told about the session it renamed. Previous is +// the name that is gone, which is the half the caller cannot look up afterwards. +type RenamedSession struct { + ID string `json:"id"` + Project string `json:"project"` + Label string `json:"label"` + Previous string `json:"previous"` +} diff --git a/api/store.go b/api/store.go new file mode 100644 index 000000000..9233db631 --- /dev/null +++ b/api/store.go @@ -0,0 +1,419 @@ +package api + +var ( + StoreCostTotals = define[CostTotalsOptions, CostReport]("store.CostTotals") + StoreLoadState = define[None, []Project]("store.LoadState") + StoreAddProject = define[AddProjectOptions, None]("store.AddProject") + StoreCloseProject = define[ProjectIDOptions, None]("store.CloseProject") + StoreReorderProjects = define[ReorderProjectsOptions, None]("store.ReorderProjects") + StoreRecentProjects = define[SearchTermOptions, []RecentProject]("store.RecentProjects") + StoreClosedProjectCount = define[SearchTermOptions, int]("store.ClosedProjectCount") + StoreAddSession = define[AddSessionOptions, None]("store.AddSession") + StoreAddSessionInFolder = define[AddSessionInFolderOptions, None]("store.AddSessionInFolder") + StoreAddSessionFrom = define[AddSessionFromOptions, None]("store.AddSessionFrom") + StoreSetActiveSession = define[ActiveSessionOptions, None]("store.SetActiveSession") + StoreCloseSession = define[ParkSessionOptions, None]("store.CloseSession") + StoreReopenSession = define[ReopenSessionOptions, *Session]("store.ReopenSession") + StoreReopenWorktreeSession = define[ReopenWorktreeSessionOptions, *Session]("store.ReopenWorktreeSession") + StorePurgeWorktreeSessions = define[WorktreeSessionsOptions, None]("store.PurgeWorktreeSessions") + StoreClosedSessions = define[SearchTermOptions, ClosedHistory]("store.ClosedSessions") + StoreExternalSessions = define[None, []ExternalSession]("store.ExternalSessions") + StoreAdoptExternalSession = define[AdoptExternalSessionOptions, None]("store.AdoptExternalSession") + StoreForgetSession = define[SessionIDOptions, None]("store.ForgetSession") + StoreMarkForked = define[SessionIDOptions, None]("store.MarkForked") + StoreReorderSessions = define[ReorderSessionsOptions, None]("store.ReorderSessions") + StoreRenameSession = define[LabelSessionOptions, None]("store.RenameSession") + StoreSetSessionColor = define[SessionColorOptions, None]("store.SetSessionColor") + StoreSetSessionFolder = define[SessionFolderOptions, None]("store.SetSessionFolder") + StoreSetSessionPinned = define[SessionPinnedOptions, None]("store.SetSessionPinned") + StoreSetSessionUnread = define[SessionUnreadOptions, None]("store.SetSessionUnread") + StoreRenameFolder = define[RenameProjectFolderOptions, []string]("store.RenameFolder") + StoreGetSetting = define[GetSettingOptions, string]("store.GetSetting") + StoreSetSetting = define[SetSettingOptions, None]("store.SetSetting") +) + +// AddProjectOptions stores a project opened at Path. +type AddProjectOptions struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` +} + +// ProjectIDOptions names a project by its id. +type ProjectIDOptions struct { + ID string `json:"id"` +} + +// ReorderProjectsOptions is the full order of the open projects. +type ReorderProjectsOptions struct { + IDs []string `json:"ids"` +} + +// SearchTermOptions narrows a listing to what matches Term, "" for all of it. +type SearchTermOptions struct { + Term string `json:"term"` +} + +// AddSessionOptions stores a new session of project ProjectID. NextSeq is the +// project's label counter after it; Sandbox is the answer to the sandbox +// question, "" to follow the provider's rung. +type AddSessionOptions struct { + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId"` + Label string `json:"label"` + Kind string `json:"kind"` + Path string `json:"path"` + NextSeq int `json:"nextSeq"` + Sandbox string `json:"sandbox"` +} + +// AddSessionInFolderOptions stores a new session filed under Folder. +type AddSessionInFolderOptions struct { + AddSessionOptions + Folder string `json:"folder"` +} + +// AddSessionFromOptions stores a session another session opened: OriginID +// and OriginLabel name that session. +type AddSessionFromOptions struct { + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId"` + Label string `json:"label"` + Kind string `json:"kind"` + Path string `json:"path"` + NextSeq int `json:"nextSeq"` + OriginID string `json:"originId"` + OriginLabel string `json:"originLabel"` + Folder string `json:"folder"` +} + +// ActiveSessionOptions makes SessionID project ProjectID's active session. +type ActiveSessionOptions struct { + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId"` +} + +// ParkSessionOptions parks session SessionID; ActiveID becomes the project's +// active session, "" for none. +type ParkSessionOptions struct { + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId"` + ActiveID string `json:"activeId"` +} + +// ReopenSessionOptions brings parked session SessionID back as NewSessionID. +type ReopenSessionOptions struct { + SessionID string `json:"sessionId"` + NewSessionID string `json:"newSessionId"` +} + +// ReopenWorktreeSessionOptions brings back the parked session of the checkout +// at Path as NewSessionID. +type ReopenWorktreeSessionOptions struct { + ProjectID string `json:"projectId"` + Path string `json:"path"` + NewSessionID string `json:"newSessionId"` +} + +// WorktreeSessionsOptions names the sessions of project ProjectID rooted in +// the checkout at Path. +type WorktreeSessionsOptions struct { + ProjectID string `json:"projectId"` + Path string `json:"path"` +} + +// AdoptExternalSessionOptions files conversation ProviderSessionID as a +// parked session of project ProjectID under SessionID. +type AdoptExternalSessionOptions struct { + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId"` + Kind string `json:"kind"` + Path string `json:"path"` + ProviderSessionID string `json:"providerSessionId"` + Label string `json:"label"` +} + +// ReorderSessionsOptions is the full card order of project ProjectID. +type ReorderSessionsOptions struct { + ProjectID string `json:"projectId"` + IDs []string `json:"ids"` +} + +// LabelSessionOptions gives session SessionID the label the user typed. +type LabelSessionOptions struct { + SessionID string `json:"sessionId"` + Label string `json:"label"` +} + +// SessionColorOptions tints session SessionID's card, "" for the theme's. +type SessionColorOptions struct { + SessionID string `json:"sessionId"` + Color string `json:"color"` +} + +// SessionFolderOptions files session SessionID under Folder, "" for none. +type SessionFolderOptions struct { + SessionID string `json:"sessionId"` + Folder string `json:"folder"` +} + +// SessionPinnedOptions pins session SessionID to the head of the sidebar. +type SessionPinnedOptions struct { + SessionID string `json:"sessionId"` + Pinned bool `json:"pinned"` +} + +// SessionUnreadOptions records whether session SessionID's finished turn is +// still unread. +type SessionUnreadOptions struct { + SessionID string `json:"sessionId"` + Unread bool `json:"unread"` +} + +// RenameProjectFolderOptions renames project ProjectID's folder From to To. +type RenameProjectFolderOptions struct { + ProjectID string `json:"projectId"` + From string `json:"from"` + To string `json:"to"` +} + +// GetSettingOptions reads setting Key of project ProjectID, "" for a global +// one. +type GetSettingOptions struct { + Key string `json:"key"` + ProjectID string `json:"projectId"` +} + +// SetSettingOptions writes Value as setting Key of project ProjectID, "" for +// a global one. +type SetSettingOptions struct { + Key string `json:"key"` + ProjectID string `json:"projectId"` + Value string `json:"value"` +} + +// CostTotalsOptions is one CostTotals call. It travels as a single JSON object +// rather than as positional arguments because a package manager replaces the +// lich binary under a running backend: a filter a newer `lich cost` adds is +// dropped by an older backend, and one an older `lich cost` does not send is no +// filter, where a positional list refuses both on its count. +type CostTotalsOptions struct { + Project string `json:"project"` + Provider string `json:"provider"` + Since int64 `json:"since"` +} + +// CostReport is CostTotals' answer: a row per project, and the same numbers +// summed across every row. +// +// The money is a **lower bound** whenever Unpriced is not zero. A session lich +// never priced holds no ledger row at all — its provider files its conversation +// where lich has no reader, a model in it has no price, or the readout was off +// while it ran — and nothing here can guess what it spent. Shipping that count +// beside the sum +// is the whole difference between "this is what it cost" and "this is what lich +// could add up", and a total that omitted it in silence would be the one +// failure mode that makes the number not worth reading. +// +// Why the row is missing is not in here on purpose: that is costMiss +// (internal/terminal/usage_cost.go), which is decided by the live scan and +// never persisted, so the ledger can only say that a session has no price — +// not which of the reasons it was. +// +// Priced and Reported are how many counted sessions each rung contributed, and +// they are what lets a reader weigh the total: the sum is the one place the +// difference between the two is invisible, since both arrive as dollars. +// +// Readout is whether the cost readout is on at all. Off, no transcript is ever +// summed, so every session is unpriced and the total can only be zero — which a +// caller has to be able to say instead of printing a $0.00 that reads as free. +type CostReport struct { + Projects []CostTotal `json:"projects"` + Sessions int `json:"sessions"` + Unpriced int `json:"unpriced"` + Priced int `json:"priced"` + Reported int `json:"reported"` + Source string `json:"source"` + CostUSD float64 `json:"costUsd"` + Readout bool `json:"readout"` +} + +// CostTotal is what one project's sessions have cost. Sessions is every session +// in the unit; Unpriced is how many of them carry no price at all, so the money +// covers Sessions-Unpriced of them. +// +// Source is whose arithmetic that money is: providers.CostSourcePriced when +// lich derived every dollar of it from token counts, providers.CostSourceReported +// when the providers that spent it handed their own figures over, and +// CostSourceMixed when the row spans both. Empty is a row with nothing to +// attribute — every session in it unpriced, or its spend earned by a provider +// CLI run by hand inside a shell session, which lich never spawned a rung for. +type CostTotal struct { + Project string `json:"project"` + Sessions int `json:"sessions"` + Unpriced int `json:"unpriced"` + Source string `json:"source"` + CostUSD float64 `json:"costUsd"` +} + +// Project is a persisted project together with its restorable session state. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + NextSeq int `json:"nextSeq"` + ActiveSessionID string `json:"activeSessionId"` + DefaultProvider string `json:"defaultProvider"` + Sessions []Session `json:"sessions"` +} + +// Session is a persisted terminal session (metadata only). Kind selects what +// the PTY runs: a provider id (see internal/providers) or "shell" (the user's +// shell). Path is the session's working directory when it lives in a git +// worktree; empty means the project's own path. ProviderSessionID is the id the +// provider CLI assigns the conversation running in the PTY, reported by that +// provider's session-start hook; empty until a hook fires (or for shell +// sessions), it is the key for features that need to reach a session's +// transcript or resume it. Pinned keeps a +// session at the head of its project's list and withholds its close affordances +// until it is unpinned. OriginSessionID and OriginLabel record the session that +// asked for this one, empty for a session nobody delegated: the id names the +// parent while it is still in the workspace, the label is what that parent was +// called at the time and is all that is left once it is closed. +type Session struct { + ID string `json:"id"` + Label string `json:"label"` + Kind string `json:"kind"` + Path string `json:"path"` + ProviderSessionID string `json:"providerSessionId"` + // Sandbox is whether this session runs confined: "on", "off", or empty for a + // row nothing has spawned yet. The spawn writes its own verdict here, so the + // window can mark a confined card without re-deriving a decision that took + // the provider's rung, the checkout and a per-session override to reach. + Sandbox string `json:"sandbox"` + Pinned bool `json:"pinned"` + // Folder is the sidebar block this session was filed under, "" for one the + // user has not filed. It rides the row rather than a preference because it + // is what the list is organised by — a workspace of forty sessions that + // came back ungrouped after a restart would be a workspace nobody files. + Folder string `json:"folder"` + // Color is the palette colour the user painted the card with, "" for one + // that follows the theme. + Color string `json:"color"` + // Profile is the Claude Code profile the session runs in + // (internal/claudeprofile), "" for Personal and for every other provider. + Profile string `json:"profile"` + // Model is the model the session was born on (SetSessionModel), "" for one + // opened on its provider's default. + Model string `json:"model"` + OriginSessionID string `json:"originSessionId"` + OriginLabel string `json:"originLabel"` + // Forked is whether the session was born branching its origin's + // conversation, rather than delegated or opened as a subagent. + Forked bool `json:"forked"` + // ScheduledAt is when the prompt below is due, in unix seconds, 0 for a + // session with nothing waiting. It rides the session row so the card that + // draws the countdown and the relay that delivers it read the same hydration + // call, and neither needs a channel of its own. + ScheduledAt int64 `json:"scheduledAt"` + ScheduledPrompt string `json:"scheduledPrompt"` + // Unread is whether this session's last finished turn is still waiting to be + // read. It rides the hydration call because the mark has to survive a window + // restart: the window holds it while it is up, and this row is what it comes + // back to (see SetSessionUnread). + Unread bool `json:"unread"` + // MCPServers names the MCP servers this session's provider could reach when + // it was spawned. It rides the row for the reason Sandbox does: the spawn is + // what resolved it — from the provider's own config documents and this + // session's directory — and a window restart has to come back to the same + // answer without re-deriving it. Nil for a row nothing has spawned yet. + MCPServers []string `json:"mcpServers"` + // SandboxSkippedLinks names the paths under the user's home this session's + // sandbox left out for being symlinks, relative to that home. It rides the + // row for the reason MCPServers does — the spawn is what resolved it, and a + // window restart has to come back to the same answer. Nil for an unconfined + // session and for one whose sandbox skipped nothing. + SandboxSkippedLinks []string `json:"sandboxSkippedLinks"` +} + +// RecentProject is a closed project offered for reopening — identity only, since the +// menu that lists them shows a name and a path and nothing else. +type RecentProject struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` +} + +// ClosedHistory is one page of the parked-session history and the size of the +// match it was cut from. Total is what lets the list say it was cut instead of +// presenting closedSessionLimit rows as the whole answer. +type ClosedHistory struct { + Sessions []ParkedSession `json:"sessions"` + Total int `json:"total"` + // Parked sessions whose conversation has not been indexed yet, which is what + // the list reports while the backfill catches up on a workspace that predates + // the index. 0 once every row has one, which is the steady state. + Indexing int `json:"indexing"` +} + +// ParkedSession is one parked session offered for resuming — what identifies it +// in a list somebody is browsing rather than one they are already looking at. +// The project rides along because history spans every project at once, closed +// ones included, and the project name is what tells two sessions of the same +// name apart. +// +// Two answers about the branch, and they are different questions: ParkedBranch +// is the snapshot this row was closed on, which is what the search matches, and +// what the window draws is read live off the checkout, because a worktree's +// directory stops agreeing with its branch the moment an agent branches inside +// it (native/cmd/lich-native/model.go, checkoutLabel). A row whose checkout is gone has +// nothing live to show — which is the same row that cannot be resumed anyway. +type ParkedSession struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + // The project's own directory, so resuming a session of a closed project can + // reopen that project first without a second lookup — and can ask where it + // went when the directory has moved. + ProjectPath string `json:"projectPath"` + Label string `json:"label"` + Kind string `json:"kind"` + Path string `json:"path"` + // The branch the checkout was on at the close, empty for a row parked before + // lich recorded one. Searchable, not drawn. + ParkedBranch string `json:"parkedBranch"` + // Unix seconds, 0 for a row parked before closed_at existed — which sorts + // last and is drawn as no date rather than as 1970. + ClosedAt int64 `json:"closedAt"` + // Whether the search reached this row through its conversation. It is what + // the window keeps the row on, apart from the snippet: a hit the index found + // by a fold the plain text does not have ("cion" against "ción"), or a term + // the index split on punctuation, has a snippet of nothing to show. + MatchedConversation bool `json:"matchedConversation"` + // The stretch of conversation that matched the search, empty for a row the + // search matched by name, for one matched by a fold (see above), and for + // every row of an empty query, which matched nothing in particular. + Snippet string `json:"snippet"` + // Whether the cap on one session's index dropped the oldest of this + // conversation. The row says so on screen: a search that reaches only part + // of a session has to be readable as that rather than as an absence. + Truncated bool `json:"truncated"` +} + +// ExternalSession is a conversation started outside lich that one of its +// projects can adopt: it ran in that project's directory or in one of its +// checkouts. Path is what the adopted row carries, "" for the project's own +// directory, the same as a session lich opened there. +type ExternalSession struct { + Kind string `json:"kind"` + ProviderSessionID string `json:"providerSessionId"` + Title string `json:"title"` + Path string `json:"path"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + ProjectPath string `json:"projectPath"` + UpdatedAt int64 `json:"updatedAt"` + // Profile is the Claude Code or Codex profile the conversation lives in, + // the one a resume of it runs in whatever the project's own profile is. + Profile string `json:"profile,omitempty"` +} diff --git a/api/system.go b/api/system.go new file mode 100644 index 000000000..f2716f908 --- /dev/null +++ b/api/system.go @@ -0,0 +1,72 @@ +package api + +var ( + SystemShowWindow = define[ShowWindowOptions, None]("system.ShowWindow") + SystemQuit = define[QuitOptions, None]("system.Quit") + SystemAttachNative = define[AttachNativeOptions, None]("system.AttachNative") + SystemDiagnostics = define[None, Diagnostics]("system.Diagnostics") + SystemNotify = define[NotifyOptions, None]("system.Notify") + SystemOpenExternal = define[OpenExternalOptions, None]("system.OpenExternal") + SystemOpenFile = define[OpenFileOptions, None]("system.OpenFile") + SystemOpenFolder = define[OpenFolderOptions, None]("system.OpenFolder") + SystemOpenFolderInEditor = define[OpenFolderOptions, string]("system.OpenFolderInEditor") + SystemRevealLog = define[None, None]("system.RevealLog") + SystemSandboxBackend = define[None, string]("system.SandboxBackend") + SystemSSHAgentKeys = define[None, []string]("system.SSHAgentKeys") + SystemTakeUncleanExit = define[None, bool]("system.TakeUncleanExit") + TraySetLabels = define[TrayLabels, None]("tray.SetLabels") +) + +// ShowWindowOptions is one ShowWindow call. It carries nothing yet; it is an +// object so a newer launch can still reach an older running lich the day it +// does (the CLI's options-object rule). +type ShowWindowOptions struct{} + +// QuitOptions is one Quit call, an object for the reason ShowWindowOptions is. +type QuitOptions struct{} + +// NotifyOptions is a desktop notification: its summary line and the detail +// under it. +type NotifyOptions struct { + Summary string `json:"summary"` + Detail string `json:"detail"` +} + +// OpenExternalOptions names the link to open in the system's browser. +type OpenExternalOptions struct { + URL string `json:"url"` +} + +// OpenFileOptions names a file by its checkout and its path inside it. +type OpenFileOptions struct { + Dir string `json:"dir"` + Rel string `json:"rel"` +} + +// OpenFolderOptions names a folder to open. +type OpenFolderOptions struct { + Dir string `json:"dir"` +} + +// AttachNativeOptions is the native window announcing itself. +type AttachNativeOptions struct { + // Executable is the native window's own binary, the one started again when + // lich is asked for its window after that window closed. + Executable string `json:"executable"` +} + +// Diagnostics is what a bug report opens with: the three facts that decide +// whether a report is actionable, and the log path the user attaches. +type Diagnostics struct { + Version string `json:"version"` + Platform string `json:"platform"` + 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. +type TrayLabels struct { + Show string `json:"show"` + Running string `json:"running"` + Quit string `json:"quit"` +} diff --git a/api/terminal.go b/api/terminal.go new file mode 100644 index 000000000..e99f8f2f5 --- /dev/null +++ b/api/terminal.go @@ -0,0 +1,152 @@ +package api + +var ( + TerminalStart = define[StartOptions, None]("terminal.Start") + TerminalClose = define[SessionIDOptions, None]("terminal.Close") + TerminalWrite = define[WriteOptions, None]("terminal.Write") + TerminalResize = define[ResizeOptions, None]("terminal.Resize") + TerminalSetVisible = define[SetVisibleOptions, None]("terminal.SetVisible") + TerminalReplay = define[SessionIDOptions, string]("terminal.Replay") + TerminalLive = define[SessionIDOptions, bool]("terminal.Live") + TerminalLiveCount = define[None, int]("terminal.LiveCount") + TerminalReady = define[SessionIDOptions, bool]("terminal.Ready") + TerminalHandsOn = define[SessionIDOptions, int64]("terminal.HandsOn") + TerminalLastTurnSaid = define[SessionIDOptions, LastSaid]("terminal.LastTurnSaid") + TerminalTurnStates = define[None, []TurnState]("terminal.TurnStates") + TerminalContinueIn = define[ContinueInOptions, None]("terminal.ContinueIn") + TerminalSearchTranscripts = define[SearchTranscriptsOptions, []TranscriptMatch]("terminal.SearchTranscripts") + TerminalWorkdirMissing = define[WorkdirOptions, bool]("terminal.WorkdirMissing") + TerminalResumeAvailable = define[ResumeAvailableOptions, bool]("terminal.ResumeAvailable") +) + +// SessionIDOptions names a session by its id. +type SessionIDOptions struct { + ID string `json:"id"` +} + +// StartOptions spawns session ID's process at Cols x Rows. Resume is a +// provider conversation to reopen, "" to start fresh; Fork branches it into a +// conversation of its own instead. +type StartOptions struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Cwd string `json:"cwd"` + Kind string `json:"kind"` + Resume string `json:"resume"` + Fork bool `json:"fork"` + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// WriteOptions types Data into session ID's PTY. +type WriteOptions struct { + ID string `json:"id"` + Data string `json:"data"` +} + +// ResizeOptions sets session ID's PTY size. +type ResizeOptions struct { + ID string `json:"id"` + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// SetVisibleOptions says whether session ID is on screen. +type SetVisibleOptions struct { + ID string `json:"id"` + Visible bool `json:"visible"` +} + +// ContinueInOptions moves session ID's conversation to Profile, a slug of +// its provider. +type ContinueInOptions struct { + ID string `json:"id"` + Profile string `json:"profile"` +} + +// SearchTranscriptsOptions searches the transcripts of sessions IDs for Query. +type SearchTranscriptsOptions struct { + IDs []string `json:"ids"` + Query string `json:"query"` +} + +// WorkdirOptions names a session's working directory. +type WorkdirOptions struct { + Cwd string `json:"cwd"` +} + +// ResumeAvailableOptions asks whether conversation ProviderSessionID of +// provider Kind can be resumed from Cwd. +type ResumeAvailableOptions struct { + Kind string `json:"kind"` + ProviderSessionID string `json:"providerSessionId"` + Cwd string `json:"cwd"` +} + +// SessionUsage is the payload of the session-usage event. Percent is the share of the +// context window the turn left occupied (0–100); Tokens is the provider's active +// context count behind it, Window the model's effective context window, Model +// the model id, and Effort the reasoning effort level ("" when the line records +// none). +// +// All five are zero for a provider that records what a turn spent but not the +// window it spent it against — oh-my-pi, opencode and Crush, the cost-only rung +// of docs/ceilings.md (each in docs/providers/). A zero Window is the window's signal to draw the cost +// alone rather than a ring around a window nobody reported. +// +// CostUSD is what the session has cost so far, and is omitted — not zeroed — +// whenever there is no number worth showing: the setting is off, or a model in +// the transcript has no known price. The readout is for API billing, so on a +// subscription the field never appears at all and the footer shows nothing. +// +// CostMiss says why, and only when the answer is "lich cannot price this +// conversation". It stays empty for the readout being +// off, which is not a failure but the design above, and for a miss the next turn +// heals. Without it a session that cannot be priced and a session that is not +// being priced look identical, and an empty footer reads as zero spend. +type SessionUsage struct { + ID string `json:"id"` + Percent int `json:"percent"` + Tokens int `json:"tokens"` + Window int `json:"window"` + Model string `json:"model"` + Effort string `json:"effort"` + CostUSD *float64 `json:"costUsd,omitempty"` + CostMiss string `json:"costMiss,omitempty"` +} + +// TurnState is what the window missed about one live session while it was not +// attached to /events: the last session-status report (State is empty for a +// session that never reported) and the readout the last session-usage event +// carried (nil when there is none to read). Since is when the session entered +// State, in Unix milliseconds. Cwd and Host are its last session-cwd report. +type TurnState struct { + ID string `json:"id"` + State string `json:"state,omitempty"` + Tool string `json:"tool,omitempty"` + Detail string `json:"detail,omitempty"` + Reason string `json:"reason,omitempty"` + Since int64 `json:"since,omitempty"` + Usage *SessionUsage `json:"usage,omitempty"` + Cwd string `json:"cwd,omitempty"` + Host string `json:"host,omitempty"` +} + +// LastSaid is the prose the agent ended its last turn with, the body of the +// window's finished-turn notification. Text is empty for every absence there is +// — a provider whose conversation lich cannot read, a turn that ended in a tool +// call, a transcript still being written — because the notification goes out +// without a body either way and a reason it cannot act on is a reason not worth +// wiring. +type LastSaid struct { + Text string `json:"text,omitempty"` +} + +// TranscriptMatch is one session whose conversation mentions the query: the +// session id to jump to, the newest matching message trimmed to a line, and how +// many of its messages matched. +type TranscriptMatch struct { + ID string `json:"id"` + Snippet string `json:"snippet"` + Count int `json:"count"` +} diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden new file mode 100644 index 000000000..e9f87f8f8 --- /dev/null +++ b/api/testdata/contract.golden @@ -0,0 +1,1683 @@ +agentplugin.Install(PluginProviderOptions) None +agentplugin.Status(None) []PluginStatus +agentplugin.Update(PluginProviderOptions) None +appupdate.Apply(None) None +appupdate.Status(None) AppUpdateStatus +claudeprofile.CancelLogin(ProfileSlugOptions) None +claudeprofile.CreateProfile(CreateProfileOptions) Profile +claudeprofile.DeleteProfile(ProfileSlugOptions) None +claudeprofile.InstallPlugins(InstallPluginsOptions) []ProfilePluginResult +claudeprofile.Login(ProfileSlugOptions) None +claudeprofile.LoginCode(LoginCodeOptions) None +claudeprofile.LoginCommand(ProfileSlugOptions) string +claudeprofile.Plugins(ProfileSlugOptions) []ProfilePlugin +claudeprofile.Profiles(None) []ProfileInfo +claudeprofile.UpdateProfile(UpdateProfileOptions) Profile +codexprofile.CancelLogin(ProfileSlugOptions) None +codexprofile.CreateProfile(CreateProfileOptions) Profile +codexprofile.DeleteProfile(ProfileSlugOptions) None +codexprofile.InstallPlugins(InstallPluginsOptions) []ProfilePluginResult +codexprofile.Login(ProfileSlugOptions) None +codexprofile.LoginCommand(ProfileSlugOptions) string +codexprofile.Plugins(ProfileSlugOptions) []ProfilePlugin +codexprofile.Profiles(None) []ProfileInfo +codexprofile.UpdateProfile(UpdateProfileOptions) Profile +cursorprofile.CreateProfile(CreateProfileOptions) Profile +cursorprofile.DeleteProfile(ProfileSlugOptions) None +cursorprofile.LoginCommand(ProfileSlugOptions) string +cursorprofile.Profiles(None) []ProfileInfo +cursorprofile.UpdateProfile(UpdateProfileOptions) Profile +drop.Attach(AttachDropOptions) DropAttachment +kiroprofile.CreateProfile(CreateProfileOptions) Profile +kiroprofile.DeleteProfile(ProfileSlugOptions) None +kiroprofile.LoginCommand(ProfileSlugOptions) string +kiroprofile.Profiles(None) []ProfileInfo +kiroprofile.UpdateProfile(UpdateProfileOptions) Profile +ompprofile.CreateProfile(CreateProfileOptions) Profile +ompprofile.DeleteProfile(ProfileSlugOptions) None +ompprofile.LoginCommand(ProfileSlugOptions) string +ompprofile.Profiles(None) []ProfileInfo +ompprofile.UpdateProfile(UpdateProfileOptions) Profile +opencodeprofile.CreateProfile(CreateProfileOptions) Profile +opencodeprofile.DeleteProfile(ProfileSlugOptions) None +opencodeprofile.LoginCommand(ProfileSlugOptions) string +opencodeprofile.Profiles(None) []ProfileInfo +opencodeprofile.UpdateProfile(UpdateProfileOptions) Profile +patchnotes.Current(PatchNotesOptions) PatchNotes +project.BaseFreshness(PathBranchOptions) BaseFreshness +project.BaseStatus(PathOptions) *BaseStatus +project.BranchRules(PathBranchOptions) *BranchRules +project.BranchesOf(PathsOptions) map[string]string +project.CarryUncommitted(CarryUncommittedOptions) None +project.CommentOnPullRequest(PRBodyOptions) None +project.CommitIdentity(PathOptions) CommitIdentity +project.CreatePullRequest(PathOptions) None +project.CreateWorktree(CreateWorktreeOptions) *Worktree +project.CreateWorktreeFromPR(CreateWorktreeFromPROptions) *Worktree +project.DeletePullRequestComment(PRCommentOptions) None +project.DeleteReviewComment(PRCommentOptions) None +project.Diff(PathOptions) DiffStats +project.DiffText(PathOptions) string +project.DiscardFile(CheckoutFileOptions) None +project.EditPullRequestBody(PRBodyOptions) None +project.EditPullRequestComment(PRCommentBodyOptions) None +project.EditPullRequestTitle(PRTitleOptions) None +project.EditReviewBody(PRReviewBodyOptions) None +project.EditReviewComment(PRCommentBodyOptions) None +project.Exists(PathOptions) bool +project.FileLines(FileLinesOptions) []string +project.GitHubAccounts(None) []string +project.Home(None) *OpenedProject +project.Issue(NumberOptions) Issue +project.ListBranches(PathOptions) Branches +project.ListCheckouts(PathOptions) []Worktree +project.ListPullRequests(ListPullRequestsOptions) []PRSummary +project.MergePullRequest(MergePullRequestOptions) None +project.Missing(PathsOptions) []string +project.Open(None) *OpenedProject +project.PickFile(PickFileOptions) string +project.PickSaveFile(PickSaveFileOptions) string +project.PullRequest(PathOptions) *PullRequest +project.PullRequestConflicts(PRConflictsOptions) []string +project.PullRequestConversation(NumberOptions) *PRConversation +project.PullRequestDetail(NumberOptions) *PRDetail +project.PullRequestDiff(NumberOptions) string +project.PullRequestMergeBase(NumberOptions) string +project.ReadFile(CheckoutFileOptions) string +project.ReadFileAt(ReadFileAtOptions) string +project.Relocate(ProjectIDOptions) *OpenedProject +project.RemoveWorktree(RemoveWorktreeOptions) None +project.ReplyToReviewThread(PRCommentBodyOptions) None +project.ResolveReviewThread(ResolveThreadOptions) None +project.RestoreLines(RestoreLinesOptions) None +project.RevertLines(RevertLinesOptions) RevertResult +project.Search(SearchOptions) SearchResult +project.SubmitReview(SubmitReviewOptions) None +project.Tree(PathOptions) FileListing +project.WorktreeAdopted(PathOptions) bool +project.WorktreeDirty(PathOptions) bool +providers.Detect(None) []DetectedProvider +providers.OnPath(ProviderIDOptions) []ProviderBinary +providers.RefreshPath(None) None +providers.Verify(BinaryOptions) BinaryCheck +providers.Version(BinaryOptions) string +quota.CodexProfilePlan(ProfilePlanOptions) QuotaPlan +quota.Plans(QuotaPlansOptions) []QuotaPlan +quota.ProfilePlan(ProfilePlanOptions) QuotaPlan +relay.AnnounceMerge(AnnounceMergeOptions) None +relay.Collect(CollectOptions) CollectedResults +relay.CollectNow(CollectNowOptions) CollectedResults +relay.Focus(FocusSessionOptions) FocusedSession +relay.Insert(InsertTextOptions) InsertedText +relay.Peers(PeersOptions) []Peer +relay.Reply(ReplyOptions) None +relay.Self(SelfOptions) Peer +relay.Send(SendOptions) SendResult +relay.SendPrivate(SendOptions) SendResult +relay.SendSubagent(SendOptions) SendResult +relay.Submit(SubmitOptions) None +relay.Wait(WaitOptions) SendResult +spawn.Ask(AskSessionOptions) SessionAnswer +spawn.Close(CloseSessionOptions) ClosedSession +spawn.ColorFolder(ColorFolderOptions) ColoredFolder +spawn.Control(ControlSessionOptions) ControlledSession +spawn.File(FileSessionOptions) FiledSession +spawn.Folders(FoldersOptions) []SessionFolder +spawn.Open(OpenSessionOptions) OpenedSession +spawn.OpenSubagent(OpenSubagentOptions) OpenedSession +spawn.Rename(RenameSessionOptions) RenamedSession +spawn.RenameFolder(RenameFolderOptions) RenamedFolder +spawn.Worktrees(WorktreesOptions) []WorktreeCheckout +store.AddProject(AddProjectOptions) None +store.AddSession(AddSessionOptions) None +store.AddSessionFrom(AddSessionFromOptions) None +store.AddSessionInFolder(AddSessionInFolderOptions) None +store.AdoptExternalSession(AdoptExternalSessionOptions) None +store.CloseProject(ProjectIDOptions) None +store.CloseSession(ParkSessionOptions) None +store.ClosedProjectCount(SearchTermOptions) int +store.ClosedSessions(SearchTermOptions) ClosedHistory +store.CostTotals(CostTotalsOptions) CostReport +store.ExternalSessions(None) []ExternalSession +store.ForgetSession(SessionIDOptions) None +store.GetSetting(GetSettingOptions) string +store.LoadState(None) []Project +store.MarkForked(SessionIDOptions) None +store.PurgeWorktreeSessions(WorktreeSessionsOptions) None +store.RecentProjects(SearchTermOptions) []RecentProject +store.RenameFolder(RenameProjectFolderOptions) []string +store.RenameSession(LabelSessionOptions) None +store.ReopenSession(ReopenSessionOptions) *Session +store.ReopenWorktreeSession(ReopenWorktreeSessionOptions) *Session +store.ReorderProjects(ReorderProjectsOptions) None +store.ReorderSessions(ReorderSessionsOptions) None +store.SetActiveSession(ActiveSessionOptions) None +store.SetSessionColor(SessionColorOptions) None +store.SetSessionFolder(SessionFolderOptions) None +store.SetSessionPinned(SessionPinnedOptions) None +store.SetSessionUnread(SessionUnreadOptions) None +store.SetSetting(SetSettingOptions) None +system.AttachNative(AttachNativeOptions) None +system.Diagnostics(None) Diagnostics +system.Notify(NotifyOptions) None +system.OpenExternal(OpenExternalOptions) None +system.OpenFile(OpenFileOptions) None +system.OpenFolder(OpenFolderOptions) None +system.OpenFolderInEditor(OpenFolderOptions) string +system.Quit(QuitOptions) None +system.RevealLog(None) None +system.SSHAgentKeys(None) []string +system.SandboxBackend(None) string +system.ShowWindow(ShowWindowOptions) None +system.TakeUncleanExit(None) bool +terminal.Close(SessionIDOptions) None +terminal.ContinueIn(ContinueInOptions) None +terminal.HandsOn(SessionIDOptions) int64 +terminal.LastTurnSaid(SessionIDOptions) LastSaid +terminal.Live(SessionIDOptions) bool +terminal.LiveCount(None) int +terminal.Ready(SessionIDOptions) bool +terminal.Replay(SessionIDOptions) string +terminal.Resize(ResizeOptions) None +terminal.ResumeAvailable(ResumeAvailableOptions) bool +terminal.SearchTranscripts(SearchTranscriptsOptions) []TranscriptMatch +terminal.SetVisible(SetVisibleOptions) None +terminal.Start(StartOptions) None +terminal.TurnStates(None) []TurnState +terminal.WorkdirMissing(WorkdirOptions) bool +terminal.Write(WriteOptions) None +themes.Import(ImportThemeOptions) ThemeImportResult +themes.InstallFromGit(InstallThemesOptions) ThemeGitResult +themes.List(None) []Theme +themes.ListBroken(None) []BrokenTheme +themes.Remove(RemoveThemeOptions) None +themes.SaveTemplate(SaveThemeTemplateOptions) None +themes.Select(SelectThemeOptions) None +themes.Selection(None) ThemeSelection +themes.SetFollowSystem(FollowSystemOptions) None +themes.UpdateFromGit(UpdateThemeOptions) ThemeGitResult +tray.SetLabels(TrayLabels) None + +event appupdate-progress AppUpdateProgress +event claude-profile-login ProfileLogin +event codex-profile-login ProfileLogin +event lich-plugin-profiles PluginProfiles +event plugin-incompatible PluginIncompatible +event profile-changed ProfileChanged +event project-added ProjectAdded +event project-closed ProjectClosed +event project-opened Project +event projects-reordered ProjectsReordered +event pull-request-changed PRChanged +event session-agent SessionAgent +event session-closed SessionClosed +event session-conversation SessionConversation +event session-cwd SessionCwd +event session-duplicate SessionDuplicate +event session-focus SessionFocus +event session-inbox SessionInbox +event session-limit SessionLimit +event session-mcp SessionMCP +event session-opened OpenedSession +event session-profile SessionProfile +event session-relay SessionRelay +event session-relay-stalled RelayStalled +event session-sandbox SessionSandbox +event session-schedule SessionSchedule +event session-schedule-forfeited ScheduleForfeited +event session-status SessionStatus +event session-title SessionTitle +event session-todo SessionTodo +event session-touched SessionTouched +event session-usage SessionUsage +event sessions-colored SessionsColored +event sessions-filed SessionsFiled +event terminal:data: string +event terminal:exit: SessionExit +event theme-selection ThemeSelection +event window-close None +event window-raise None + +PluginProviderOptions { + provider string +} + +None { +} + +PluginStatus { + provider string + name string + available bool + installed bool + installedVersion string + latestVersion string + updateAvailable bool + compatible bool +} + +AppUpdateStatus { + currentVersion string + latestVersion string + updateAvailable bool + canSelfApply bool + releaseUrl string + installCommand string +} + +ProfileSlugOptions { + slug string +} + +CreateProfileOptions { + name string +} + +Profile { + slug string + name string + dir string + personal bool + copied,omitempty []string + color,omitempty string + image,omitempty base64 +} + +InstallPluginsOptions { + slug string + keys []string +} + +ProfilePluginResult { + key string + ok bool + message,omitempty string +} + +LoginCodeOptions { + slug string + code string +} + +ProfilePlugin { + key string + name string + marketplace string + version,omitempty string +} + +ProfileInfo { + ...Profile + loggedIn bool + email,omitempty string + orgName,omitempty string + plan,omitempty string + authMethod,omitempty string + plugins int + providers,omitempty []string + statusError,omitempty string +} + +UpdateProfileOptions { + slug string + update ProfileUpdate +} + +AttachDropOptions { + sessionId string + root string + confined bool +} + +DropAttachment { + path string + notice string +} + +PatchNotesOptions { + locale string +} + +PatchNotes { + version string + highlights []PatchNotesHighlight + groups []PatchNotesGroup +} + +PathBranchOptions { + path string + branch string +} + +BaseFreshness { + upstream string + ahead int + behind int + fetchError,omitempty string +} + +PathOptions { + path string +} + +BaseStatus { + base string + behind int + conflicts []string +} + +BranchRules { + allowedMergeMethods []string + commitPatterns []CommitPattern + viewerCanBypass bool +} + +PathsOptions { + paths []string +} + +CarryUncommittedOptions { + src string + dst string +} + +PRBodyOptions { + path string + number int + body string +} + +CommitIdentity { + name string + email string + local bool +} + +CreateWorktreeOptions { + projectPath string + projectId string + name string + base string + baseIsRemote bool +} + +Worktree { + name string + path string + reused,omitempty bool +} + +CreateWorktreeFromPROptions { + projectPath string + projectId string + number int +} + +PRCommentOptions { + path string + number int + commentId int64 +} + +DiffStats { + files int + added int + deleted int + head string + branch string +} + +CheckoutFileOptions { + path string + rel string +} + +PRCommentBodyOptions { + path string + number int + commentId int64 + body string +} + +PRTitleOptions { + path string + number int + title string +} + +PRReviewBodyOptions { + path string + number int + reviewId int64 + body string +} + +FileLinesOptions { + path string + rel string + ref string + from int + to int +} + +OpenedProject { + id string + name string + path string +} + +NumberOptions { + path string + number int +} + +Issue { + number int + title string + body string + url string +} + +Branches { + local []string + remote []string + worktrees []Worktree +} + +ListPullRequestsOptions { + path string + state string +} + +PRSummary { + number int + title string + author string + state string + isDraft bool + reviewDecision string + headRefName string + isCrossRepository bool + updatedAt string + checks ChecksRollup + threads ThreadTally +} + +MergePullRequestOptions { + path string + number int + method string + subject string + body string + admin bool +} + +PickFileOptions { + title string +} + +PickSaveFileOptions { + title string + defaultName string +} + +PullRequest { + number int + url string + state string +} + +PRConflictsOptions { + path string + number int + base string + prUrl string +} + +PRConversation { + headRefOid string + reviews []PRReview + comments []PRComment + threads []PRReviewThread +} + +PRDetail { + number int + url string + title string + body string + author string + authorLogin string + state string + isDraft bool + mergeable string + mergeStateStatus string + reviewDecision string + reviewers []PRReviewer + baseRefName string + headRefName string + changedFiles int + checks ChecksRollup + checkRuns []CheckItem + commits []PRCommit + isCrossRepository bool + maintainerCanModify bool +} + +ReadFileAtOptions { + path string + rel string + ref string +} + +ProjectIDOptions { + id string +} + +RemoveWorktreeOptions { + projectPath string + worktreePath string + force bool + adoptedAck bool +} + +ResolveThreadOptions { + path string + threadId string + resolved bool +} + +RestoreLinesOptions { + path string + rel string + undo RevertResult +} + +RevertLinesOptions { + path string + rel string + lines []RevertLine +} + +RevertResult (MarshalJSON) { + patch base64 + indexPatch base64 +} + +SearchOptions { + path string + query string +} + +SearchResult { + matches []SearchMatch + cut bool + tooLarge int +} + +SubmitReviewOptions { + path string + number int + event string + body string + head string + comments []ReviewComment +} + +FileListing { + files []string + cut bool + hidden []string +} + +DetectedProvider { + id string + name string + binary string + installed bool + path string + source string + docs string +} + +ProviderIDOptions { + id string +} + +ProviderBinary { + path string + version string +} + +BinaryOptions { + bin string +} + +BinaryCheck { + path string + status string +} + +ProfilePlanOptions { + profile string +} + +QuotaPlan { + provider string + name string + plan,omitempty string + account,omitempty string + noAccount,omitempty string + windows,omitempty []QuotaWindow + status string +} + +QuotaPlansOptions { + sessionId string +} + +AnnounceMergeOptions { + checkout string + number int + title string + branch string + base string +} + +CollectOptions { + from string + waitSeconds int +} + +CollectedResults { + results []SendResult + open []string +} + +CollectNowOptions { + from string +} + +FocusSessionOptions { + from string + target string + project string +} + +FocusedSession { + id string + label string + project string +} + +InsertTextOptions { + from string + target string + project string + text string + waitSeconds int +} + +InsertedText { + id string + label string + project string + kind string + bytes int +} + +PeersOptions { + from string +} + +Peer { + label string + name string + project string + kind string + state string + id string + path string + projectPath string +} + +ReplyOptions { + from string + ticket string + answer string +} + +SelfOptions { + from string +} + +SendOptions { + from string + target string + project string + prompt string + waitSeconds int +} + +SendResult { + ticket string + target string + status string + answer string + private,omitempty bool +} + +SubmitOptions { + sessionId string + message string +} + +WaitOptions { + ticket string + waitSeconds int +} + +AskSessionOptions { + from string + target string + project string + question string +} + +SessionAnswer { + id string + project string + label string + answer string +} + +CloseSessionOptions { + from string + target string + project string + worktree string + force bool +} + +ClosedSession { + id string + projectId string + project string + label string + worktree string + kept bool + removed bool +} + +ColorFolderOptions { + from string + project string + folder string + color string +} + +ColoredFolder { + project string + folder string + color string + sessions []string +} + +ControlSessionOptions { + from string + target string + project string + action string + value string + args string +} + +ControlledSession { + id string + project string + label string + action string + value,omitempty string + command_id string + state string +} + +FileSessionOptions { + from string + target string + project string + folder string +} + +FiledSession { + id string + project string + label string + folder string + previous string +} + +FoldersOptions { + from string + project string +} + +SessionFolder { + name string + sessions []string +} + +OpenSessionOptions { + from string + project string + kind string + worktree string + base string + model string + effort string + folder string + ultracode bool +} + +OpenedSession { + id string + projectId string + project string + label string + name string + kind string + path string + nextSeq int + originSessionId string + originLabel string + confined bool + folder string +} + +OpenSubagentOptions { + from string + kind string + worktree string + base string + model string + effort string + ultracode bool +} + +RenameSessionOptions { + from string + target string + project string + label string +} + +RenamedSession { + id string + project string + label string + previous string +} + +RenameFolderOptions { + from string + project string + folder string + to string +} + +RenamedFolder { + project string + from string + to string + sessions []string +} + +WorktreesOptions { + from string + project string +} + +WorktreeCheckout { + name string + path string + dirty bool + sessions []string +} + +AddProjectOptions { + id string + name string + path string +} + +AddSessionOptions { + projectId string + sessionId string + label string + kind string + path string + nextSeq int + sandbox string +} + +AddSessionFromOptions { + projectId string + sessionId string + label string + kind string + path string + nextSeq int + originId string + originLabel string + folder string +} + +AddSessionInFolderOptions { + ...AddSessionOptions + folder string +} + +AdoptExternalSessionOptions { + projectId string + sessionId string + kind string + path string + providerSessionId string + label string +} + +ParkSessionOptions { + projectId string + sessionId string + activeId string +} + +SearchTermOptions { + term string +} + +ClosedHistory { + sessions []ParkedSession + total int + indexing int +} + +CostTotalsOptions { + project string + provider string + since int64 +} + +CostReport { + projects []CostTotal + sessions int + unpriced int + priced int + reported int + source string + costUsd float64 + readout bool +} + +ExternalSession { + kind string + providerSessionId string + title string + path string + projectId string + projectName string + projectPath string + updatedAt int64 + profile,omitempty string +} + +SessionIDOptions { + id string +} + +GetSettingOptions { + key string + projectId string +} + +Project { + id string + name string + path string + nextSeq int + activeSessionId string + defaultProvider string + sessions []Session +} + +WorktreeSessionsOptions { + projectId string + path string +} + +RecentProject { + id string + name string + path string +} + +RenameProjectFolderOptions { + projectId string + from string + to string +} + +LabelSessionOptions { + sessionId string + label string +} + +ReopenSessionOptions { + sessionId string + newSessionId string +} + +Session { + id string + label string + kind string + path string + providerSessionId string + sandbox string + pinned bool + folder string + color string + profile string + model string + originSessionId string + originLabel string + forked bool + scheduledAt int64 + scheduledPrompt string + unread bool + mcpServers []string + sandboxSkippedLinks []string +} + +ReopenWorktreeSessionOptions { + projectId string + path string + newSessionId string +} + +ReorderProjectsOptions { + ids []string +} + +ReorderSessionsOptions { + projectId string + ids []string +} + +ActiveSessionOptions { + projectId string + sessionId string +} + +SessionColorOptions { + sessionId string + color string +} + +SessionFolderOptions { + sessionId string + folder string +} + +SessionPinnedOptions { + sessionId string + pinned bool +} + +SessionUnreadOptions { + sessionId string + unread bool +} + +SetSettingOptions { + key string + projectId string + value string +} + +AttachNativeOptions { + executable string +} + +Diagnostics { + version string + platform string + logPath string +} + +NotifyOptions { + summary string + detail string +} + +OpenExternalOptions { + url string +} + +OpenFileOptions { + dir string + rel string +} + +OpenFolderOptions { + dir string +} + +QuitOptions { +} + +ShowWindowOptions { +} + +ContinueInOptions { + id string + profile string +} + +LastSaid { + text,omitempty string +} + +ResizeOptions { + id string + cols int + rows int +} + +ResumeAvailableOptions { + kind string + providerSessionId string + cwd string +} + +SearchTranscriptsOptions { + ids []string + query string +} + +TranscriptMatch { + id string + snippet string + count int +} + +SetVisibleOptions { + id string + visible bool +} + +StartOptions { + id string + projectId string + cwd string + kind string + resume string + fork bool + cols int + rows int +} + +TurnState { + id string + state,omitempty string + tool,omitempty string + detail,omitempty string + reason,omitempty string + since,omitempty int64 + usage,omitempty *SessionUsage + cwd,omitempty string + host,omitempty string +} + +WorkdirOptions { + cwd string +} + +WriteOptions { + id string + data string +} + +ImportThemeOptions { + path string + overwrite bool +} + +ThemeImportResult { + theme Theme + needsOverwrite bool +} + +InstallThemesOptions { + url string + overwrite bool +} + +ThemeGitResult { + pack string + version string + themes []Theme + conflicts []string + upToDate bool +} + +Theme { + formatVersion,omitempty int + id string + name string + scheme string + origin string + source,omitempty *ThemeSource + app map[string]string + terminal map[string]string + syntax,omitempty Syntax + pair,omitempty string +} + +BrokenTheme { + id string + reason string +} + +RemoveThemeOptions { + id string +} + +SaveThemeTemplateOptions { + path string +} + +SelectThemeOptions { + id string +} + +ThemeSelection { + theme string + followSystem bool +} + +FollowSystemOptions { + on bool +} + +UpdateThemeOptions { + id string +} + +TrayLabels { + show string + running string + quit string +} + +AppUpdateProgress { + phase string + received int64 + total int64 +} + +ProfileLogin { + profile string + output,omitempty string + url,omitempty string + authenticating,omitempty bool + done,omitempty bool + loggedIn,omitempty bool + cancelled,omitempty bool + error,omitempty string + pluginError,omitempty string +} + +PluginProfiles { + provider string + name string + version string + failures []PluginProfileFailure +} + +PluginIncompatible { + id string + version string +} + +ProfileChanged { + provider string + profile Profile +} + +ProjectAdded { + id string + name string + path string +} + +ProjectClosed { + id string +} + +ProjectsReordered { + ids []string +} + +PRChanged { + path string + number int +} + +SessionAgent { + id string + agent string +} + +SessionClosed { + id string + projectId string + activeId string +} + +SessionConversation { + id string + providerSessionId string +} + +SessionCwd { + id string + cwd string + host string +} + +SessionDuplicate { + id string + heldBy string +} + +SessionFocus { + id string +} + +SessionInbox { + id string + count int +} + +SessionLimit { + id string + window string + resetsAt int64 +} + +SessionMCP { + id string + servers []string +} + +SessionProfile { + id string + profile string +} + +SessionRelay { + id string + peer string + direction string + ticket string +} + +RelayStalled { + id string + targetId string + target string +} + +SessionSandbox { + id string + confined bool + skippedLinks []string +} + +SessionSchedule { + id string + at int64 + prompt,omitempty string +} + +ScheduleForfeited { + label string + at int64 + prompt string +} + +SessionStatus { + id string + state string + tool,omitempty string + detail,omitempty string + reason,omitempty string +} + +SessionTitle { + id string + label string +} + +SessionTodo { + id string + done int + total int +} + +SessionTouched { + id string +} + +SessionUsage { + id string + percent int + tokens int + window int + model string + effort string + costUsd,omitempty *float64 + costMiss,omitempty string +} + +SessionsColored { + projectId string + ids []string + color string +} + +SessionsFiled { + projectId string + ids []string + folder string +} + +SessionExit { + code,omitempty *int +} + +ProfileUpdate { + name,omitempty string + color,omitempty *string + image,omitempty *string +} + +PatchNotesHighlight { + kind string + text string +} + +PatchNotesGroup { + label string + items []string +} + +CommitPattern { + target string + operator string + pattern string + negate bool +} + +ChecksRollup { + passed int + failed int + pending int + total int +} + +ThreadTally { + open int + total int +} + +PRReview { + id string + databaseId int64 + author string + state string + body string + date string + canEdit bool +} + +PRComment { + id int64 + author string + body string + date string + canEdit bool + canDelete bool +} + +PRReviewThread { + id string + path string + line int + startLine int + side string + isResolved bool + isOutdated bool + comments []PRThreadComment +} + +PRReviewer { + login string + isTeam bool + state string +} + +CheckItem { + name string + state string + description string + url string + startedAt string + completedAt string + required bool +} + +PRCommit { + oid string + headline string + body string + login string + name string + email string + date string +} + +RevertLine { + side string + line int + text string +} + +SearchMatch { + path string + line int + text string +} + +ReviewComment { + path string + line int + startLine int + side string + body string +} + +QuotaWindow { + label string + seconds int + percent int + resetsAt,omitempty string + active,omitempty bool + lockedReason,omitempty string + ahead,omitempty bool +} + +ParkedSession { + id string + projectId string + projectName string + projectPath string + label string + kind string + path string + parkedBranch string + closedAt int64 + matchedConversation bool + snippet string + truncated bool +} + +CostTotal { + project string + sessions int + unpriced int + source string + costUsd float64 +} + +ThemeSource { + url string + version string +} + +Syntax = map[string]SyntaxStyle + +PluginProfileFailure { + profile string + name string + error string +} + +PRThreadComment { + id int64 + author string + body string + date string + diffHunk string + canEdit bool + canDelete bool +} + +SyntaxStyle { + color,omitempty string + bold,omitempty bool + italic,omitempty bool + underline,omitempty bool + strikethrough,omitempty bool +} diff --git a/api/themes.go b/api/themes.go new file mode 100644 index 000000000..726396b84 --- /dev/null +++ b/api/themes.go @@ -0,0 +1,158 @@ +package api + +import ( + "encoding/json" + "fmt" +) + +var ( + ThemesList = define[None, []Theme]("themes.List") + ThemesListBroken = define[None, []BrokenTheme]("themes.ListBroken") + ThemesImport = define[ImportThemeOptions, ThemeImportResult]("themes.Import") + ThemesInstallFromGit = define[InstallThemesOptions, ThemeGitResult]("themes.InstallFromGit") + ThemesUpdateFromGit = define[UpdateThemeOptions, ThemeGitResult]("themes.UpdateFromGit") + ThemesRemove = define[RemoveThemeOptions, None]("themes.Remove") + ThemesSaveTemplate = define[SaveThemeTemplateOptions, None]("themes.SaveTemplate") + ThemesSelection = define[None, ThemeSelection]("themes.Selection") + ThemesSelect = define[SelectThemeOptions, None]("themes.Select") + ThemesSetFollowSystem = define[FollowSystemOptions, None]("themes.SetFollowSystem") +) + +// Theme describes every color value the window needs to paint the app and +// the terminal. +type Theme struct { + FormatVersion int `json:"formatVersion,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Scheme string `json:"scheme"` + Origin string `json:"origin"` + Source *ThemeSource `json:"source,omitempty"` + App map[string]string `json:"app"` + Terminal map[string]string `json:"terminal"` + // Syntax is optional: a theme without it is highlighted in the window's + // default palette for its scheme. + Syntax Syntax `json:"syntax,omitempty"` + // Pair is optional: the id of this theme's variant for the other scheme, + // which the window draws while the OS asks for that scheme and the user + // turned on following it. + Pair string `json:"pair,omitempty"` +} + +// BrokenTheme is a stored custom theme this lich cannot load. It is listed +// rather than dropped, so the user sees why and can remove it by id. +type BrokenTheme struct { + ID string `json:"id"` + Reason string `json:"reason"` +} + +// ThemeSource records the repository a theme was installed from and the +// manifest version it came in at, so lich can clone it again to look for a +// newer one. It is written by lich, never by a picked file: a standalone import +// has no source and stays unversioned. +type ThemeSource struct { + URL string `json:"url"` + Version string `json:"version"` +} + +// SyntaxStyle is how the code under one highlighting tag is drawn: a +// CodeMirror HighlightStyle spec. A theme file writes it as a bare color or +// as an object; Color empty leaves the code its foreground color. +type SyntaxStyle struct { + Color string `json:"color,omitempty"` + Bold bool `json:"bold,omitempty"` + Italic bool `json:"italic,omitempty"` + Underline bool `json:"underline,omitempty"` + Strikethrough bool `json:"strikethrough,omitempty"` +} + +// Syntax is a theme's syntax highlighting, keyed by the tag each style is +// for, as @lezer/highlight prints it: "keyword", "function(variableName)". +type Syntax map[string]SyntaxStyle + +// UnmarshalJSON reads each entry as a bare color or a style object, naming +// the entry that is neither. +func (s *Syntax) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("syntax must be an object of tag styles: %w", err) + } + out := make(Syntax, len(raw)) + for tag, entry := range raw { + var style SyntaxStyle + if err := json.Unmarshal(entry, &style.Color); err != nil { + if err := json.Unmarshal(entry, &style); err != nil { + return fmt.Errorf("syntax.%s must be a color or a style object", tag) + } + } + out[tag] = style + } + *s = out + return nil +} + +// ThemeImportResult either reports the installed theme or asks the UI to +// confirm replacing the existing custom theme with the same id. +type ThemeImportResult struct { + Theme Theme `json:"theme"` + NeedsOverwrite bool `json:"needsOverwrite"` +} + +// ThemeGitResult reports what installing a repository produced. Conflicts +// names the theme ids that already exist locally: found by the scan that runs +// before any write, nothing was installed at all; found by the write itself — a +// theme that appeared in between — that one id was left alone and Themes says +// what did go in. +type ThemeGitResult struct { + Pack string `json:"pack"` + Version string `json:"version"` + Themes []Theme `json:"themes"` + Conflicts []string `json:"conflicts"` + UpToDate bool `json:"upToDate"` +} + +// ThemeSelection is the theme the user chose and whether the window draws +// that theme's pair while the OS asks for the pair's scheme. +type ThemeSelection struct { + Theme string `json:"theme"` + FollowSystem bool `json:"followSystem"` +} + +// ImportThemeOptions names the theme file to import; Overwrite replaces a +// custom theme of the same id instead of asking. +type ImportThemeOptions struct { + Path string `json:"path"` + Overwrite bool `json:"overwrite"` +} + +// InstallThemesOptions names the theme repository to clone. +type InstallThemesOptions struct { + URL string `json:"url"` + Overwrite bool `json:"overwrite"` +} + +// UpdateThemeOptions names a theme whose repository is cloned again. +type UpdateThemeOptions struct { + ID string `json:"id"` +} + +// RemoveThemeOptions names the custom theme to delete. +type RemoveThemeOptions struct { + ID string `json:"id"` +} + +// SaveThemeTemplateOptions is where the starter theme is written. +type SaveThemeTemplateOptions struct { + Path string `json:"path"` +} + +// SelectThemeOptions names the theme to store as the selection: "system" or +// an installed theme's id. +type SelectThemeOptions struct { + ID string `json:"id"` +} + +// FollowSystemOptions is whether the window follows the OS to the selected +// theme's pair. +type FollowSystemOptions struct { + On bool `json:"on"` +} diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md index 74c20a372..3a9e31328 100644 --- a/docs/adding-a-provider.md +++ b/docs/adding-a-provider.md @@ -87,7 +87,7 @@ everything on the id (`provider..bin`, `.enabled`, `.sandbox`, | `native/cmd/lich-native/settings_providers.go` | its `skipFlags` spelling (the switch in Settings is hidden for a provider absent here, which is what stops the UI promising a flag the spawn has none for), and a `loginCommands` entry only if `internal/quota` reads a plan for it | | `native/cmd/lich-native/toolline.go` | a rule only if it spells MCP tool names in a shape not already handled | | `native/cmd/lich-native/footer_handson.go` | a rung in `handsOnRung`: whether the hands-on clock hears that provider through a turn its hooks open, or only through its tool calls. No compiler checks it, and a provider missing from it reads as a turn one | -| `native/lichclient/store.go` | nothing, unless the change moves a JSON tag: the `DetectedProvider` mirror is hand-owned and moves in the same commit | +| `native/backend/store.go` | nothing: `DetectedProvider` is `api`'s, so a JSON tag moves in `api/providers.go` and in `api/testdata/contract.golden` | `Registry.Docs` is the one field with a check outside the compiler: it is what the "Not found on PATH" row and the no-agents dialog link to, so a blank or dead one diff --git a/docs/ceilings.md b/docs/ceilings.md index a66b2b42b..d71dab17c 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -551,14 +551,14 @@ work when nobody knows it and that the call site never shows. The mechanism and an older client does not send is its zero value; the version probe `system.Diagnostics` takes nothing at all, which has nothing to misalign. What is left: a method newer than the backend is refused as unknown, and the release that moves a call to the object (a parameterless one included, the day it takes one) is refused by a - lich from before it, once, with "want 1 arguments, got N" (or the other way round). The RPC itself still - matches arguments by position and count, so a call `lich` makes must not go back to positional arguments: - `TestEveryCallTheCLIPostsTakesOneOptionsObject` (`internal/cli/rpcshape_test.go`) reads every method name the - CLI's source posts and fails on one whose parameters are anything but one struct or none, and - `internal/cli/wire_test.go` posts each one as a client of another release. The window's own calls - (`native/lichclient`) stay positional, on the promise that `lich-native` ships in the same release as the - backend it talks to; a window from another build, pinned with `LICH_NATIVE` or replaced on disk by an upgrade - under a running backend, is the case that promise does not cover. + lich from before it, once, with "want 1 arguments, got N" (or the other way round); a backend from the typed + contract on reads an empty argument list as the zero options, so only the older one refuses. Every call `lich` + and `lich mcp` make goes through a method `api` declares (`callMethod` in `internal/cli/transport.go` is the + one way the CLI posts), whose request `api`'s `TestEveryRequestIsOneObject` holds to one struct, and + `internal/cli/wire_test.go` posts each one as a client of another release. The window's calls go through the + same declarations (`native/backend`), so a window from another build, pinned with `LICH_NATIVE` or replaced + on disk by an upgrade under a running backend, is answered the way a `lich` of another release is, and reads + the `/events` stream through the same declarations. - **A release's highlight is read from the binary, so fixing it after the tag fixes nothing a user sees** (`internal/patchnotes`): the What's new dialog parses the `CHANGELOG.md` embedded at build time — the alert blocks under the version heading included. Editing that release on GitHub, or the changelog on `main`, @@ -926,7 +926,7 @@ work when nobody knows it and that the call site never shows. The mechanism and (`exitMarker`, `native/cmd/lich-native/terminal_exit.go`), because it is data in the replay and not a label; the `Session N` label a session gets when nothing names it (`internal/spawn/spawn.go`), because it is persisted and would otherwise change language on the next launch only for the rows made afterwards; - every error and the output of `lichclient.ErrorText`, which show backend and library text as it came; the + every error and the output of `backend.ErrorText`, which show backend and library text as it came; the CHANGELOG group labels in the What's new dialog (`native/cmd/lich-native/settings_updates_notes.go`), which render a file that is English; and MCP tool descriptions and the `lich` CLI's `--help`, which agents read and which do not follow the prompt diff --git a/docs/native-window.md b/docs/native-window.md index 2c50823ad..5236a2e97 100644 --- a/docs/native-window.md +++ b/docs/native-window.md @@ -40,8 +40,9 @@ from `main` either drowns in merges or freezes the web app for months. window is a client of that backend. - **`native/` is its own Go module**, built into its own binary, `lich-native`. It talks to the backend only through the backend's loopback contract (RPC, `/events`, `/ws`, and `/blob` for an image's bytes), never by - importing `internal/`. That keeps the window honest against one contract, and keeps the backend's own - build free of cgo. + importing `internal/`; the RPC's methods, the `/events` payloads and their types are the public `api` + package both modules compile against. That keeps the window honest against one contract, and keeps the + backend's own build free of cgo. - **`lich`** starts the backend and launches `lich-native` found beside the `lich` binary (or the one `LICH_NATIVE` pins) with the backend's port and token in its environment. A window that cannot start shows an error dialog with the reason and the log path; lich then quits when no session is running, and diff --git a/internal/agentplugin/agentplugin.go b/internal/agentplugin/agentplugin.go index 1dc93b8e6..f464481d1 100644 --- a/internal/agentplugin/agentplugin.go +++ b/internal/agentplugin/agentplugin.go @@ -32,6 +32,7 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/ghrelease" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/semver" @@ -134,26 +135,7 @@ func New(bins BinResolver) *Service { } } -// Status is one provider's plugin state, reported to the window. -type Status struct { - Provider string `json:"provider"` - Name string `json:"name"` - // Available is whether the provider's CLI is on PATH at all. A provider the - // machine does not have is still listed — with nothing to install — so the - // UI can say why it is not on offer. - Available bool `json:"available"` - Installed bool `json:"installed"` - InstalledVersion string `json:"installedVersion"` - // LatestVersion is the newest release this lich is compatible with, not the - // newest the plugin has published: it is the one an install or update writes. - LatestVersion string `json:"latestVersion"` - UpdateAvailable bool `json:"updateAvailable"` - // Compatible is false when the installed release is outside the range this - // lich speaks (PluginVersionFloor, PluginVersionCeiling). Its reports still - // land where they parse; UpdateAvailable then offers LatestVersion, a - // downgrade included. - Compatible bool `json:"compatible"` -} +type Status = api.PluginStatus // Status reports, for every provider that can run the plugin, whether its CLI // is present, whether the plugin is installed there and whether a newer release diff --git a/internal/agentplugin/profiles.go b/internal/agentplugin/profiles.go index f00b400a5..b250d48ce 100644 --- a/internal/agentplugin/profiles.go +++ b/internal/agentplugin/profiles.go @@ -4,10 +4,13 @@ import ( "log/slog" "os" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/claudeprofile" "github.com/lichdotdev/lich/internal/codexprofile" "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" + + "github.com/lichdotdev/lich/internal/events" ) // Claude Code and Codex keep a plugin set per profile, so the lich plugin @@ -21,26 +24,9 @@ import ( // add` both install into a logged-out home (measured on Claude Code 2.1.296 and // codex-cli 0.162.1), and the install after a login runs again anyway. -// ProfilesEventName is the event a pass over a provider's profiles reports on, -// once it has installed into at least one. -const ProfilesEventName = "lich-plugin-profiles" - -// ProfilesEvent is what one pass did: the release it installed and the -// profiles where the install failed, in the CLI's own words. Name is the -// provider's display name. -type ProfilesEvent struct { - Provider string `json:"provider"` - Name string `json:"name"` - Version string `json:"version"` - Failures []ProfileFailure `json:"failures"` -} +type ProfilesEvent = api.PluginProfiles -// ProfileFailure is one profile the plugin could not be installed into. -type ProfileFailure struct { - Profile string `json:"profile"` - Name string `json:"name"` - Error string `json:"error"` -} +type ProfileFailure = api.PluginProfileFailure // profileProvider is what a provider with profiles brings to the pass. // ensureShared brings a profile to the layout a spawn would before its plugins @@ -80,7 +66,7 @@ var profileProviders = map[string]profileProvider{ }, } -// SetEmit wires where a pass over the profiles reports (ProfilesEventName). +// SetEmit wires where a pass over the profiles reports (api.PluginProfilesEvent). // Startup wiring; without it Install reaches Personal alone. func (s *Service) SetEmit(emit func(name string, data any)) { s.emit = emit } @@ -129,7 +115,7 @@ func (s *Service) followPersonal(provider string) { } } if installed > 0 && s.emit != nil { - s.emit(ProfilesEventName, ev) + events.Send(s.emit, api.PluginProfilesEvent, ev) } } diff --git a/internal/agentplugin/profiles_test.go b/internal/agentplugin/profiles_test.go index e85b69b67..8a90ed00b 100644 --- a/internal/agentplugin/profiles_test.go +++ b/internal/agentplugin/profiles_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/claudeprofile" "github.com/lichdotdev/lich/internal/codexprofile" "github.com/lichdotdev/lich/internal/providers" @@ -91,7 +92,7 @@ func ranAs(lines []string, dir string) int { func recordEvents(svc *Service) <-chan ProfilesEvent { events := make(chan ProfilesEvent, 4) svc.SetEmit(func(name string, data any) { - if name == ProfilesEventName { + if name == api.PluginProfilesEvent.Name { events <- data.(ProfilesEvent) } }) diff --git a/internal/appupdate/appupdate.go b/internal/appupdate/appupdate.go index a39504f70..89698fb06 100644 --- a/internal/appupdate/appupdate.go +++ b/internal/appupdate/appupdate.go @@ -19,6 +19,7 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/ghrelease" "github.com/lichdotdev/lich/internal/semver" ) @@ -73,7 +74,7 @@ const ( type Service struct { mu sync.Mutex install func(string) error - // emit publishes Apply's progress to the app's event hub (ProgressEventName); + // emit publishes Apply's progress to the app's event hub (api.AppUpdateProgressEvent); // nil leaves the update working and silent. emit func(name string, data any) @@ -120,17 +121,7 @@ func New(version string, install func(string) error, emit func(name string, data } } -// Status is lich's install/update state, reported to the window. -type Status struct { - CurrentVersion string `json:"currentVersion"` - LatestVersion string `json:"latestVersion"` - UpdateAvailable bool `json:"updateAvailable"` - CanSelfApply bool `json:"canSelfApply"` - ReleaseURL string `json:"releaseUrl"` - // InstallCommand is the shell command the UI pastes (never auto-runs) to - // update a package-manager-owned install; empty on the self-apply platforms. - InstallCommand string `json:"installCommand"` -} +type Status = api.AppUpdateStatus // Status reports whether a newer release exists and whether this install can // update itself through the installer. A failed network lookup leaves everything empty and diff --git a/internal/appupdate/progress.go b/internal/appupdate/progress.go index 72489d07a..8178ce0ff 100644 --- a/internal/appupdate/progress.go +++ b/internal/appupdate/progress.go @@ -3,20 +3,12 @@ package appupdate import ( "io" "time" -) -// ProgressEventName is the hub event Apply's phases ride on, for the toast. -const ProgressEventName = "appupdate-progress" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) -// Progress is one step of Apply: bytes received of total during the download -// (total is -1 when the asset came without a Content-Length), then the phase -// that has no percentage: "installer", where lich hands over to the Windows -// installer and closes. -type Progress struct { - Phase string `json:"phase"` - Received int64 `json:"received"` - Total int64 `json:"total"` -} +type Progress = api.AppUpdateProgress const ( phaseDownload = "download" @@ -76,6 +68,6 @@ func (p *progressReader) report() { // updates in silence. func (s *Service) emitProgress(p Progress) { if s.emit != nil { - s.emit(ProgressEventName, p) + events.Send(s.emit, api.AppUpdateProgressEvent, p) } } diff --git a/internal/appupdate/progress_test.go b/internal/appupdate/progress_test.go index 6a2319a06..886658166 100644 --- a/internal/appupdate/progress_test.go +++ b/internal/appupdate/progress_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "github.com/lichdotdev/lich/api" ) // collectProgress returns a Service whose emit appends every step to the @@ -12,7 +14,7 @@ import ( func collectProgress() (*Service, *[]Progress) { steps := &[]Progress{} s := &Service{emit: func(name string, data any) { - if name != ProgressEventName { + if name != api.AppUpdateProgressEvent.Name { panic("unexpected event " + name) } *steps = append(*steps, data.(Progress)) diff --git a/internal/claudeprofile/login.go b/internal/claudeprofile/login.go index ea6fd6ca2..f0167b97c 100644 --- a/internal/claudeprofile/login.go +++ b/internal/claudeprofile/login.go @@ -7,10 +7,10 @@ import ( "io" "regexp" "time" -) -// LoginEventName is the event a login reports its progress on. -const LoginEventName = "claude-profile-login" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) // loginTimeout bounds a login lich started: a browser tab the user walked away // from must not leave a CLI waiting for good. @@ -20,23 +20,7 @@ const loginTimeout = 10 * time.Minute // didn't open, visit: https://…", measured on 2.1.296). var loginURL = regexp.MustCompile(`https://\S+`) -// LoginEvent is one step of a login. Output carries what the CLI printed, with -// URL set when that text holds the sign-in address. Authenticating says the CLI -// is through with the browser and lich is reading whether the login took. The -// last event has Done set: -// LoggedIn is what `claude auth status` answered afterwards, Error why the login -// failed or was cancelled, and PluginError why the lich plugin did not install. -type LoginEvent struct { - Profile string `json:"profile"` - Output string `json:"output,omitempty"` - URL string `json:"url,omitempty"` - Authenticating bool `json:"authenticating,omitempty"` - Done bool `json:"done,omitempty"` - LoggedIn bool `json:"loggedIn,omitempty"` - Cancelled bool `json:"cancelled,omitempty"` - Error string `json:"error,omitempty"` - PluginError string `json:"pluginError,omitempty"` -} +type LoginEvent = api.ProfileLogin type login struct { cancel context.CancelFunc @@ -96,7 +80,7 @@ func (s *Service) relayOutput(slug string, out io.Reader) { n, err := out.Read(buf) if n > 0 { text := string(buf[:n]) - s.emit(LoginEventName, LoginEvent{Profile: slug, Output: text, URL: loginURL.FindString(text)}) + events.Send(s.emit, api.ClaudeProfileLoginEvent, LoginEvent{Profile: slug, Output: text, URL: loginURL.FindString(text)}) } if err != nil { return @@ -117,14 +101,14 @@ func (s *Service) finishLogin(ctx context.Context, p Profile, run *login, waitEr case errors.Is(ended, context.Canceled): done.Cancelled = true done.Error = "login cancelled" - s.emit(LoginEventName, done) + events.Send(s.emit, api.ClaudeProfileLoginEvent, done) return case errors.Is(ended, context.DeadlineExceeded): done.Error = fmt.Sprintf("login timed out after %s", loginTimeout) - s.emit(LoginEventName, done) + events.Send(s.emit, api.ClaudeProfileLoginEvent, done) return } - s.emit(LoginEventName, LoginEvent{Profile: p.Slug, Authenticating: true}) + events.Send(s.emit, api.ClaudeProfileLoginEvent, LoginEvent{Profile: p.Slug, Authenticating: true}) st, err := s.authStatus(p) switch { case err != nil: @@ -138,7 +122,7 @@ func (s *Service) finishLogin(ctx context.Context, p Profile, run *login, waitEr done.PluginError = err.Error() } } - s.emit(LoginEventName, done) + events.Send(s.emit, api.ClaudeProfileLoginEvent, done) } // LoginCode hands a running login the code the sign-in page showed, for a diff --git a/internal/claudeprofile/service.go b/internal/claudeprofile/service.go index 984d7d69f..d13c3697e 100644 --- a/internal/claudeprofile/service.go +++ b/internal/claudeprofile/service.go @@ -14,9 +14,12 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/winexec" + + "github.com/lichdotdev/lich/internal/events" ) const ( @@ -80,23 +83,7 @@ func (s *Service) SetSessions(inUse func(slug string) int, forget func(slug stri s.inUse, s.forget = inUse, forget } -// Info is a profile as the Settings screen shows it: who is logged in there and -// on which plan, and how many plugins it has. -type Info struct { - Profile - LoggedIn bool `json:"loggedIn"` - Email string `json:"email,omitempty"` - OrgName string `json:"orgName,omitempty"` - // Plan is Claude Code's own subscription name: "pro", "max", "team", …. - Plan string `json:"plan,omitempty"` - // AuthMethod is how the profile authenticates: "claude.ai", "console", or - // "none" when nobody is logged in. - AuthMethod string `json:"authMethod,omitempty"` - Plugins int `json:"plugins"` - // StatusError is why `claude auth status` gave no answer, in its own words; - // LoggedIn is then false for want of one. - StatusError string `json:"statusError,omitempty"` -} +type Info = api.ProfileInfo // authStatus is the JSON `claude auth status` prints on stdout (measured on // 2.1.296), on a logged-out profile too, exiting 1. @@ -166,7 +153,7 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er if err != nil { return p, err } - s.emit(profilestore.ChangedEvent, profilestore.Changed{Provider: store.Provider, Profile: p}) + events.Send(s.emit, api.ProfileChangedEvent, profilestore.Changed{Provider: store.Provider, Profile: p}) return p, nil } @@ -190,13 +177,7 @@ func (s *Service) DeleteProfile(slug string) error { return nil } -// Plugin is one plugin a profile has installed at user scope. -type Plugin struct { - Key string `json:"key"` - Name string `json:"name"` - Marketplace string `json:"marketplace"` - Version string `json:"version,omitempty"` -} +type Plugin = api.ProfilePlugin // Plugins lists the plugins a profile has installed at user scope, the ones a // port can carry to another profile. The lich plugin is not among them. @@ -246,13 +227,7 @@ func installedPlugins(plugins string) []Plugin { return out } -// PluginResult is one plugin's install, with Claude Code's own words when it -// refused: an organization's policy, a marketplace the profile cannot reach. -type PluginResult struct { - Key string `json:"key"` - OK bool `json:"ok"` - Message string `json:"message,omitempty"` -} +type PluginResult = api.ProfilePluginResult // InstallPlugins installs each plugin@marketplace into a profile through Claude // Code's own `plugin install`, run as that profile so its account's policy diff --git a/internal/claudeprofile/service_test.go b/internal/claudeprofile/service_test.go index 966ac052d..230c01bf6 100644 --- a/internal/claudeprofile/service_test.go +++ b/internal/claudeprofile/service_test.go @@ -9,6 +9,8 @@ import ( "sync" "testing" "time" + + "github.com/lichdotdev/lich/api" ) // fakeClaudeGuard turns the test binary into a stand-in for the Claude Code CLI @@ -78,7 +80,7 @@ func newRecorder() *recorder { return &recorder{done: make(chan LoginEvent, 1)} func (r *recorder) emit(name string, data any) { e, ok := data.(LoginEvent) - if name != LoginEventName || !ok { + if name != api.ClaudeProfileLoginEvent.Name || !ok { return } r.mu.Lock() diff --git a/internal/claudeprofile/update_test.go b/internal/claudeprofile/update_test.go index add10d2ec..a40de9469 100644 --- a/internal/claudeprofile/update_test.go +++ b/internal/claudeprofile/update_test.go @@ -3,6 +3,7 @@ package claudeprofile import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" ) @@ -10,7 +11,7 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { machine(t) var got []profilestore.Changed s := New(fakeBins{}, func(name string, data any) { - if c, ok := data.(profilestore.Changed); ok && name == profilestore.ChangedEvent { + if c, ok := data.(profilestore.Changed); ok && name == api.ProfileChangedEvent.Name { got = append(got, c) } }) diff --git a/internal/cli/ask.go b/internal/cli/ask.go index 8f5825c55..38670c0fb 100644 --- a/internal/cli/ask.go +++ b/internal/cli/ask.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/spawn" ) @@ -27,10 +28,10 @@ func (c *client) ask(args []string) error { return usageError("ask") } - var out spawn.Answered question := strings.Join(flags.Args()[1:], " ") - call := []any{spawn.AskOptions{From: c.sessionID(), Target: flags.Arg(0), Project: *project, Question: question}} - if err := c.call(context.Background(), "spawn.Ask", call, askCall, &out); err != nil { + opts := spawn.AskOptions{From: c.sessionID(), Target: flags.Arg(0), Project: *project, Question: question} + out, err := callMethod(context.Background(), c, api.SpawnAsk, opts, askCall) + if err != nil { return err } if *asJSON { @@ -60,12 +61,11 @@ var askTools = []mcpTool{ "exists in more than one."), }, "session", "question"), Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { - var out spawn.Answered - call := []any{spawn.AskOptions{ + out, err := callMethod(ctx, c, api.SpawnAsk, spawn.AskOptions{ From: c.sessionID(), Target: args.text("session"), Project: args.text("project"), Question: args.text("question"), - }} - if err := c.call(ctx, "spawn.Ask", call, askCall, &out); err != nil { + }, askCall) + if err != nil { return "", err } return out.Answer, nil diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 69235f92c..adf9fa608 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -35,6 +35,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/doctor" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/rage" @@ -72,17 +73,20 @@ const callSlack = 30 * time.Second // shortCall bounds the commands that do not wait on another session. const shortCall = 10 * time.Second +// sendCall is an RPC method that hands a session a task. +type sendCall = api.Method[relay.SendOptions, relay.Result] + // The two RPC methods that hand a session a task: an ordinary send, and one // whose result is kept to its ticket (relay.SendPrivate). -const ( - relaySend = "relay.Send" - relaySendPrivate = "relay.SendPrivate" +var ( + relaySend = api.RelaySend + relaySendPrivate = api.RelaySendPrivate // relaySendSubagent hands the task to a worker opened with --subagent // (relay.SendSubagent). - relaySendSubagent = "relay.SendSubagent" + relaySendSubagent = api.RelaySendSubagent ) -func sendMethod(private bool) string { +func sendMethod(private bool) sendCall { if private { return relaySendPrivate } @@ -300,8 +304,8 @@ func (c *client) sessions(args []string) error { return usageError("sessions") } - var peers []relay.Peer - if err := c.call(context.Background(), "relay.Peers", []any{relay.PeersOptions{From: c.sessionID()}}, shortCall, &peers); err != nil { + peers, err := callMethod(context.Background(), c, api.RelayPeers, relay.PeersOptions{From: c.sessionID()}, shortCall) + if err != nil { return err } peers = asList(peers) @@ -333,8 +337,8 @@ func (c *client) whoami(args []string) error { return usageError("whoami") } - var self relay.Peer - if err := c.call(context.Background(), "relay.Self", []any{relay.SelfOptions{From: c.sessionID()}}, shortCall, &self); err != nil { + self, err := callMethod(context.Background(), c, api.RelaySelf, relay.SelfOptions{From: c.sessionID()}, shortCall) + if err != nil { return err } if *asJSON { @@ -374,11 +378,10 @@ func (c *client) send(args []string) error { } method := sendMethod(*private) - var result relay.Result - call := []any{relay.SendOptions{ + result, err := callMethod(context.Background(), c, method, relay.SendOptions{ From: c.sessionID(), Target: flags.Arg(0), Project: *project, Prompt: flags.Arg(1), WaitSeconds: *timeout, - }} - if err := c.call(context.Background(), method, call, waitBudget(*timeout), &result); err != nil { + }, waitBudget(*timeout)) + if err != nil { return err } if err := c.report(result, *asJSON); err != nil { @@ -417,11 +420,10 @@ func (c *client) insert(args []string) error { return err } - var inserted relay.Inserted - call := []any{relay.InsertOptions{ + inserted, err := callMethod(context.Background(), c, api.RelayInsert, relay.InsertOptions{ From: c.sessionID(), Target: *session, Project: *project, Text: text, WaitSeconds: *timeout, - }} - if err := c.call(context.Background(), "relay.Insert", call, waitBudget(*timeout), &inserted); err != nil { + }, waitBudget(*timeout)) + if err != nil { return err } if *asJSON { @@ -454,9 +456,8 @@ func (c *client) focus(args []string) error { allowWindowForeground() target := strings.TrimSuffix(strings.TrimPrefix(flags.Arg(0), sessionLinkScheme+":"), "/") - var focused relay.Focused - call := []any{relay.FocusOptions{From: c.sessionID(), Target: target, Project: *project}} - if err := c.call(context.Background(), "relay.Focus", call, shortCall, &focused); err != nil { + focused, err := callMethod(context.Background(), c, api.RelayFocus, relay.FocusOptions{From: c.sessionID(), Target: target, Project: *project}, shortCall) + if err != nil { return err } if *asJSON { @@ -526,10 +527,7 @@ func (c *client) showVersion(args []string) error { return usageError("version") } report := versionReport{CLI: c.version} - var diagnostics struct { - Version string `json:"version"` - } - if c.call(context.Background(), "system.Diagnostics", []any{}, versionCall, &diagnostics) == nil { + if diagnostics, err := callMethod(context.Background(), c, api.SystemDiagnostics, api.None{}, versionCall); err == nil { report.Server = diagnostics.Version } if *asJSON { @@ -569,9 +567,8 @@ func (c *client) wait(args []string) error { return collectedOutcome(collected) } - var result relay.Result - call := []any{relay.WaitOptions{Ticket: flags.Arg(0), WaitSeconds: *timeout}} - if err := c.call(context.Background(), "relay.Wait", call, waitBudget(*timeout), &result); err != nil { + result, err := callMethod(context.Background(), c, api.RelayWait, relay.WaitOptions{Ticket: flags.Arg(0), WaitSeconds: *timeout}, waitBudget(*timeout)) + if err != nil { return err } if err := c.report(result, *asJSON); err != nil { @@ -583,10 +580,13 @@ func (c *client) wait(args []string) error { // collect drains this session's inbox, holding the line for the next result // unless noWait asks for only what is ready. func (c *client) collect(ctx context.Context, noWait bool, timeout int, out *relay.Collected) error { + var err error if noWait { - return c.call(ctx, "relay.CollectNow", []any{relay.CollectNowOptions{From: c.sessionID()}}, shortCall, out) + *out, err = callMethod(ctx, c, api.RelayCollectNow, relay.CollectNowOptions{From: c.sessionID()}, shortCall) + return err } - return c.call(ctx, "relay.Collect", []any{relay.CollectOptions{From: c.sessionID(), WaitSeconds: timeout}}, waitBudget(timeout), out) + *out, err = callMethod(ctx, c, api.RelayCollect, relay.CollectOptions{From: c.sessionID(), WaitSeconds: timeout}, waitBudget(timeout)) + return err } func (c *client) reply(args []string) error { @@ -604,8 +604,8 @@ func (c *client) reply(args []string) error { if flags.NArg() == 2 { ticket, answer = flags.Arg(0), flags.Arg(1) } - call := []any{relay.ReplyOptions{From: c.sessionID(), Ticket: ticket, Answer: answer}} - if err := c.call(context.Background(), "relay.Reply", call, shortCall, nil); err != nil { + opts := relay.ReplyOptions{From: c.sessionID(), Ticket: ticket, Answer: answer} + if _, err := callMethod(context.Background(), c, api.RelayReply, opts, shortCall); err != nil { return err } fmt.Fprintln(c.stdout, "Answer sent.") @@ -642,24 +642,24 @@ func (c *client) open(args []string) error { return fmt.Errorf("--private applies to the task handed over with --prompt: add a prompt, or drop --private") } method := sendMethod(*private) - spawnMethod := "spawn.Open" - call := []any{spawn.OpenOptions{ - From: c.sessionID(), Project: *project, Kind: *kind, Worktree: *worktree, Base: *base, - Model: *model, Effort: *effort, Folder: *folder, Ultracode: *ultracode, - }} + var opened spawn.Session + var err error if *subagent { if err := c.checkSubagent(*prompt, *project, *folder, *private); err != nil { return err } - method, spawnMethod = relaySendSubagent, "spawn.OpenSubagent" - call = []any{spawn.OpenSubagentOptions{ + method = relaySendSubagent + opened, err = callMethod(context.Background(), c, api.SpawnOpenSubagent, spawn.OpenSubagentOptions{ From: c.sessionID(), Kind: *kind, Worktree: *worktree, Base: *base, Model: *model, Effort: *effort, Ultracode: *ultracode, - }} + }, openCall) + } else { + opened, err = callMethod(context.Background(), c, api.SpawnOpen, spawn.OpenOptions{ + From: c.sessionID(), Project: *project, Kind: *kind, Worktree: *worktree, Base: *base, + Model: *model, Effort: *effort, Folder: *folder, Ultracode: *ultracode, + }, openCall) } - - var opened spawn.Session - if err := c.call(context.Background(), spawnMethod, call, openCall, &opened); err != nil { + if err != nil { return err } delivered, failure := c.handOff(opened, *prompt, method) @@ -724,7 +724,7 @@ type opening struct { // handOff gives a just-opened session its first task, and nothing at all when // no task came with it. The failure it returns names the session, because the // session outlives it: what went wrong is one send, not the open. -func (c *client) handOff(opened spawn.Session, prompt, method string) (*relay.Result, error) { +func (c *client) handOff(opened spawn.Session, prompt string, method sendCall) (*relay.Result, error) { if prompt == "" { return nil, nil } @@ -749,13 +749,10 @@ func (c *client) handOff(opened spawn.Session, prompt, method string) (*relay.Re // command line's --prompt and the open_session tool's, which differ in how they // word the outcome and in nothing else. method is relay.Send or // relay.SendPrivate, as the caller asked. -func (c *client) deliver(ctx context.Context, opened spawn.Session, prompt, method string) (relay.Result, error) { - var result relay.Result - call := []any{relay.SendOptions{ +func (c *client) deliver(ctx context.Context, opened spawn.Session, prompt string, method sendCall) (relay.Result, error) { + return callMethod(ctx, c, method, relay.SendOptions{ From: c.sessionID(), Target: opened.Label, Project: opened.Project, Prompt: prompt, WaitSeconds: deliverWait, - }} - err := c.call(ctx, method, call, waitBudget(deliverWait), &result) - return result, err + }, waitBudget(deliverWait)) } // openedText words a new session for whoever asked for one. It names both of @@ -905,11 +902,10 @@ func (c *client) close(args []string) error { return usageError("close") } - var closed spawn.Closed - call := []any{spawn.CloseOptions{ + closed, err := callMethod(context.Background(), c, api.SpawnClose, spawn.CloseOptions{ From: c.sessionID(), Target: flags.Arg(0), Project: *project, Worktree: *worktree, Force: *force, - }} - if err := c.call(context.Background(), "spawn.Close", call, openCall, &closed); err != nil { + }, openCall) + if err != nil { return err } if *asJSON { @@ -956,9 +952,9 @@ func (c *client) rename(args []string) error { return usageError("rename") } - var renamed spawn.Renamed - call := []any{spawn.RenameOptions{From: c.sessionID(), Target: target, Project: *project, Label: label}} - if err := c.call(context.Background(), "spawn.Rename", call, shortCall, &renamed); err != nil { + opts := spawn.RenameOptions{From: c.sessionID(), Target: target, Project: *project, Label: label} + renamed, err := callMethod(context.Background(), c, api.SpawnRename, opts, shortCall) + if err != nil { return err } if *asJSON { @@ -988,9 +984,9 @@ func (c *client) worktrees(args []string) error { return usageError("worktrees") } - var checkouts []spawn.Checkout - call := []any{spawn.WorktreesOptions{From: c.sessionID(), Project: *project}} - if err := c.call(context.Background(), "spawn.Worktrees", call, shortCall, &checkouts); err != nil { + opts := spawn.WorktreesOptions{From: c.sessionID(), Project: *project} + checkouts, err := callMethod(context.Background(), c, api.SpawnWorktrees, opts, shortCall) + if err != nil { return err } checkouts = asList(checkouts) diff --git a/internal/cli/control.go b/internal/cli/control.go index 2d097e753..c106b5853 100644 --- a/internal/cli/control.go +++ b/internal/cli/control.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/spawn" ) @@ -28,12 +29,11 @@ func (c *client) control(args []string) error { return usageError("control") } - var out spawn.Controlled - call := []any{spawn.ControlOptions{ + out, err := callMethod(context.Background(), c, api.SpawnControl, spawn.ControlOptions{ From: c.sessionID(), Target: flags.Arg(0), Project: *project, Action: flags.Arg(1), Value: flags.Arg(2), Args: flags.Arg(3), - }} - if err := c.call(context.Background(), "spawn.Control", call, controlCall, &out); err != nil { + }, controlCall) + if err != nil { return err } if *asJSON { @@ -121,12 +121,11 @@ var controlTools = []mcpTool{ "exists in more than one."), }, "session", "action"), Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { - var out spawn.Controlled - call := []any{spawn.ControlOptions{ + out, err := callMethod(ctx, c, api.SpawnControl, spawn.ControlOptions{ From: c.sessionID(), Target: args.text("session"), Project: args.text("project"), Action: args.text("action"), Value: args.text("value"), Args: args.text("args"), - }} - if err := c.call(ctx, "spawn.Control", call, controlCall, &out); err != nil { + }, controlCall) + if err != nil { return "", err } return controlledText(out), nil diff --git a/internal/cli/cost.go b/internal/cli/cost.go index 177364cca..c9d51db9a 100644 --- a/internal/cli/cost.go +++ b/internal/cli/cost.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) @@ -31,9 +32,8 @@ func (c *client) cost(args []string) error { return err } - var report store.CostReport - call := []any{store.CostTotalsOptions{Project: *project, Provider: *provider, Since: from}} - if err := c.call(context.Background(), "store.CostTotals", call, shortCall, &report); err != nil { + report, err := callMethod(context.Background(), c, api.StoreCostTotals, store.CostTotalsOptions{Project: *project, Provider: *provider, Since: from}, shortCall) + if err != nil { return err } report.Projects = asList(report.Projects) diff --git a/internal/cli/folders.go b/internal/cli/folders.go index 1d527476b..86885da5a 100644 --- a/internal/cli/folders.go +++ b/internal/cli/folders.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/spawn" ) @@ -31,9 +32,9 @@ func (c *client) folders(args []string) error { return usageError("folders") } - var folders []spawn.Folder - call := []any{spawn.FoldersOptions{From: c.sessionID(), Project: *project}} - if err := c.call(context.Background(), "spawn.Folders", call, shortCall, &folders); err != nil { + opts := spawn.FoldersOptions{From: c.sessionID(), Project: *project} + folders, err := callMethod(context.Background(), c, api.SpawnFolders, opts, shortCall) + if err != nil { return err } folders = asList(folders) @@ -73,9 +74,9 @@ func (c *client) file(args []string) error { return usageError("file") } - var filed spawn.Filed - call := []any{spawn.FileOptions{From: c.sessionID(), Target: target, Project: *project, Folder: folder}} - if err := c.call(context.Background(), "spawn.File", call, shortCall, &filed); err != nil { + opts := spawn.FileOptions{From: c.sessionID(), Target: target, Project: *project, Folder: folder} + filed, err := callMethod(context.Background(), c, api.SpawnFile, opts, shortCall) + if err != nil { return err } if *asJSON { @@ -98,11 +99,10 @@ func (c *client) renameFolder(args []string) error { return usageError("rename-folder") } - var refiled spawn.Refiled - call := []any{spawn.RenameFolderOptions{ + refiled, err := callMethod(context.Background(), c, api.SpawnRenameFolder, spawn.RenameFolderOptions{ From: c.sessionID(), Project: *project, Folder: flags.Arg(0), To: flags.Arg(1), - }} - if err := c.call(context.Background(), "spawn.RenameFolder", call, shortCall, &refiled); err != nil { + }, shortCall) + if err != nil { return err } if *asJSON { @@ -125,11 +125,10 @@ func (c *client) colorFolder(args []string) error { return usageError("color-folder") } - var colored spawn.Colored - call := []any{spawn.ColorFolderOptions{ + colored, err := callMethod(context.Background(), c, api.SpawnColorFolder, spawn.ColorFolderOptions{ From: c.sessionID(), Project: *project, Folder: flags.Arg(0), Color: flags.Arg(1), - }} - if err := c.call(context.Background(), "spawn.ColorFolder", call, shortCall, &colored); err != nil { + }, shortCall) + if err != nil { return err } if *asJSON { @@ -187,9 +186,9 @@ var folderTools = []mcpTool{ }), ReadOnly: true, Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { - var folders []spawn.Folder - call := []any{spawn.FoldersOptions{From: c.sessionID(), Project: args.text("project")}} - if err := c.call(ctx, "spawn.Folders", call, shortCall, &folders); err != nil { + opts := spawn.FoldersOptions{From: c.sessionID(), Project: args.text("project")} + folders, err := callMethod(ctx, c, api.SpawnFolders, opts, shortCall) + if err != nil { return "", err } out, err := json.Marshal(asList(folders)) @@ -223,12 +222,11 @@ var folderTools = []mcpTool{ return "", errors.New("name the folder to file the session under; " + "an empty one takes the session out of the folder it is in") } - var filed spawn.Filed - call := []any{spawn.FileOptions{ + filed, err := callMethod(ctx, c, api.SpawnFile, spawn.FileOptions{ From: c.sessionID(), Target: args.text("session"), Project: args.text("project"), Folder: args.text("folder"), - }} - if err := c.call(ctx, "spawn.File", call, shortCall, &filed); err != nil { + }, shortCall) + if err != nil { return "", err } return filedText(filed), nil @@ -251,12 +249,11 @@ var folderTools = []mcpTool{ if _, named := args["to"]; !named { return "", errors.New("name the folder's new name; an empty one takes the folder apart") } - var refiled spawn.Refiled - call := []any{spawn.RenameFolderOptions{ + refiled, err := callMethod(ctx, c, api.SpawnRenameFolder, spawn.RenameFolderOptions{ From: c.sessionID(), Project: args.text("project"), Folder: args.text("folder"), To: args.text("to"), - }} - if err := c.call(ctx, "spawn.RenameFolder", call, shortCall, &refiled); err != nil { + }, shortCall) + if err != nil { return "", err } return refiledText(refiled), nil @@ -281,12 +278,11 @@ var folderTools = []mcpTool{ if _, named := args["color"]; !named { return "", errors.New("name the color to paint the folder; an empty one clears it") } - var colored spawn.Colored - call := []any{spawn.ColorFolderOptions{ + colored, err := callMethod(ctx, c, api.SpawnColorFolder, spawn.ColorFolderOptions{ From: c.sessionID(), Project: args.text("project"), Folder: args.text("folder"), Color: args.text("color"), - }} - if err := c.call(ctx, "spawn.ColorFolder", call, shortCall, &colored); err != nil { + }, shortCall) + if err != nil { return "", err } return coloredText(colored), nil diff --git a/internal/cli/hangup_test.go b/internal/cli/hangup_test.go index 886356ea3..81880e821 100644 --- a/internal/cli/hangup_test.go +++ b/internal/cli/hangup_test.go @@ -16,6 +16,8 @@ import ( "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/rpc" + + "github.com/lichdotdev/lich/internal/routes" ) // These prove that a caller who stops waiting for results stops taking them. A @@ -36,7 +38,7 @@ func wiredRelay(t *testing.T) (func(string) string, *wiredTerminal, *relay.Servi term := &wiredTerminal{} svc := relay.New(wiredSessions{}, term, nil) dispatcher := rpc.New() - dispatcher.Register("relay", svc) + routes.Relay(dispatcher, svc) gate := collectGate{entered: make(chan struct{}, 8), left: make(chan struct{}, 8)} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { collecting := strings.HasSuffix(r.URL.Path, "relay.Collect") diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 434a6ad96..f4b0c3e4a 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/spawn" @@ -480,8 +481,8 @@ var mcpTools = append([]mcpTool{ Schema: schema(map[string]any{}), ReadOnly: true, Run: func(ctx context.Context, c *client, _ mcpArgs) (string, error) { - var peers []relay.Peer - if err := c.call(ctx, "relay.Peers", []any{relay.PeersOptions{From: c.sessionID()}}, shortCall, &peers); err != nil { + peers, err := callMethod(ctx, c, api.RelayPeers, relay.PeersOptions{From: c.sessionID()}, shortCall) + if err != nil { return "", err } if len(peers) == 0 { @@ -521,13 +522,12 @@ var mcpTools = append([]mcpTool{ }, "session", "prompt"), Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { method := sendMethod(args.flag("private")) - var result relay.Result timeout := args.seconds("timeout_seconds") - call := []any{relay.SendOptions{ + result, err := callMethod(ctx, c, method, relay.SendOptions{ From: c.sessionID(), Target: args.text("session"), Project: args.text("project"), Prompt: args.text("prompt"), WaitSeconds: timeout, - }} - if err := c.call(ctx, method, call, waitBudget(timeout), &result); err != nil { + }, waitBudget(timeout)) + if err != nil { return "", err } return mcpOutcome(c.lang(), result), nil @@ -561,9 +561,8 @@ var mcpTools = append([]mcpTool{ return "", fmt.Errorf("no_wait collects everything that is ready and takes no ticket: " + "drop the ticket to look without waiting, or drop no_wait to wait on it") } - var result relay.Result - call := []any{relay.WaitOptions{Ticket: ticket, WaitSeconds: timeout}} - if err := c.call(ctx, "relay.Wait", call, waitBudget(timeout), &result); err != nil { + result, err := callMethod(ctx, c, api.RelayWait, relay.WaitOptions{Ticket: ticket, WaitSeconds: timeout}, waitBudget(timeout)) + if err != nil { return "", err } return mcpOutcome(c.lang(), result), nil @@ -634,13 +633,12 @@ var mcpTools = append([]mcpTool{ return "", fmt.Errorf("private applies to the task handed over with prompt: add a prompt, or drop private") } method := sendMethod(args.flag("private")) - var opened spawn.Session - call := []any{spawn.OpenOptions{ + opened, err := callMethod(ctx, c, api.SpawnOpen, spawn.OpenOptions{ From: c.sessionID(), Project: args.text("project"), Kind: args.text("kind"), Worktree: args.text("worktree"), Base: args.text("base"), Model: args.text("model"), Effort: args.text("effort"), Folder: args.text("folder"), Ultracode: args.flag("ultracode"), - }} - if err := c.call(ctx, "spawn.Open", call, openCall, &opened); err != nil { + }, openCall) + if err != nil { return "", err } if prompt == "" { @@ -672,12 +670,11 @@ var mcpTools = append([]mcpTool{ "Remove a checkout that still has uncommitted work. Ask the user first."), }, "session"), Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { - var closed spawn.Closed - call := []any{spawn.CloseOptions{ + closed, err := callMethod(ctx, c, api.SpawnClose, spawn.CloseOptions{ From: c.sessionID(), Target: args.text("session"), Project: args.text("project"), Worktree: args.text("worktree"), Force: args.flag("force"), - }} - if err := c.call(ctx, "spawn.Close", call, openCall, &closed); err != nil { + }, openCall) + if err != nil { return "", err } return closedText(c.lang(), closed), nil @@ -701,12 +698,11 @@ var mcpTools = append([]mcpTool{ "exists in more than one."), }, "label"), Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { - var renamed spawn.Renamed - call := []any{spawn.RenameOptions{ + renamed, err := callMethod(ctx, c, api.SpawnRename, spawn.RenameOptions{ From: c.sessionID(), Target: args.text("session"), Project: args.text("project"), Label: args.text("label"), - }} - if err := c.call(ctx, "spawn.Rename", call, shortCall, &renamed); err != nil { + }, shortCall) + if err != nil { return "", err } return renamedText(renamed), nil @@ -724,9 +720,9 @@ var mcpTools = append([]mcpTool{ }), ReadOnly: true, Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { - var checkouts []spawn.Checkout - call := []any{spawn.WorktreesOptions{From: c.sessionID(), Project: args.text("project")}} - if err := c.call(ctx, "spawn.Worktrees", call, shortCall, &checkouts); err != nil { + opts := spawn.WorktreesOptions{From: c.sessionID(), Project: args.text("project")} + checkouts, err := callMethod(ctx, c, api.SpawnWorktrees, opts, shortCall) + if err != nil { return "", err } out, err := json.Marshal(asList(checkouts)) @@ -751,8 +747,8 @@ var mcpTools = append([]mcpTool{ "answer": property("string", "Your answer, in full — nothing else is sent back."), }, "answer"), Run: func(ctx context.Context, c *client, args mcpArgs) (string, error) { - call := []any{relay.ReplyOptions{From: c.sessionID(), Ticket: args.text("ticket"), Answer: args.text("answer")}} - if err := c.call(ctx, "relay.Reply", call, shortCall, nil); err != nil { + opts := relay.ReplyOptions{From: c.sessionID(), Ticket: args.text("ticket"), Answer: args.text("answer")} + if _, err := callMethod(ctx, c, api.RelayReply, opts, shortCall); err != nil { return "", err } return "Answer sent.", nil @@ -768,7 +764,7 @@ var mcpTools = append([]mcpTool{ // failed call. The session exists either way, and a failed call would read as if // nothing had happened — leaving an agent that opens three workers convinced it // has none. -func (c *client) handOver(ctx context.Context, opened spawn.Session, task, method string) string { +func (c *client) handOver(ctx context.Context, opened spawn.Session, task string, method sendCall) string { result, err := c.deliver(ctx, opened, task, method) if err != nil { return handOverFailure(c.lang(), err, method) @@ -778,7 +774,7 @@ func (c *client) handOver(ctx context.Context, opened spawn.Session, task, metho // handOverFailure words a first task that did not reach a session that is open // all the same, naming the call that sends it again. -func handOverFailure(lang prompt.Lang, err error, method string) string { +func handOverFailure(lang prompt.Lang, err error, method sendCall) string { text := prompt.For(lang) send := "send_to_session" if method == relaySendPrivate { diff --git a/internal/cli/quit.go b/internal/cli/quit.go index 120f76be3..6802841e1 100644 --- a/internal/cli/quit.go +++ b/internal/cli/quit.go @@ -8,6 +8,7 @@ import ( "net/url" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/system" ) @@ -34,7 +35,7 @@ func (c *client) quit(args []string) error { if err != nil { return err } - asked := c.call(context.Background(), "system.Quit", []any{system.QuitOptions{}}, shortCall, nil) + _, asked := callMethod(context.Background(), c, api.SystemQuit, system.QuitOptions{}, shortCall) // A lich that exits fast enough takes the reply down with it, and its port // can outlive that connection by a moment, so only a call lich answered is // a refusal; one that got no reply is settled by waiting for the port. diff --git a/internal/cli/rpcshape_test.go b/internal/cli/rpcshape_test.go deleted file mode 100644 index 2ba4aaa4f..000000000 --- a/internal/cli/rpcshape_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package cli - -import ( - "context" - "go/ast" - "go/parser" - "go/token" - "os" - "reflect" - "regexp" - "strconv" - "strings" - "testing" - - "github.com/lichdotdev/lich/internal/relay" - "github.com/lichdotdev/lich/internal/spawn" - "github.com/lichdotdev/lich/internal/store" - "github.com/lichdotdev/lich/internal/system" - "github.com/lichdotdev/lich/internal/themes" -) - -// rpcMethodName is how the CLI spells a method it posts: ".". -var rpcMethodName = regexp.MustCompile(`^[a-z]+\.[A-Z][A-Za-z]*$`) - -// cliServices are the services the CLI posts to, by the name main.go registers -// them under. -var cliServices = map[string]reflect.Type{ - "spawn": reflect.TypeFor[*spawn.Service](), - "relay": reflect.TypeFor[*relay.Service](), - "store": reflect.TypeFor[*store.Service](), - "system": reflect.TypeFor[*system.Service](), - "themes": reflect.TypeFor[*themes.Service](), -} - -// TestEveryCallTheCLIPostsTakesOneOptionsObject keeps a call `lich` or `lich -// mcp` makes from going back to positional arguments. The RPC matches those by -// position and count, and a package manager replaces the binary under a running -// backend, so a positional call breaks on the day its argument list changes -// (docs/ceilings.md). One JSON object, after an optional context, is what lets -// a newer and an older lich understand each other. A call with no input yet, -// like the version probe, has nothing to misalign and may take nothing; the day -// it takes something, that something is the object. -func TestEveryCallTheCLIPostsTakesOneOptionsObject(t *testing.T) { - t.Parallel() - methods := postedMethods(t) - if !methods["spawn.Open"] { - t.Fatalf("found %v, want spawn.Open among them: the scan no longer sees the CLI's calls", methods) - } - for name := range methods { - service, method, _ := strings.Cut(name, ".") - svc, ok := cliServices[service] - if !ok { - t.Errorf("%s: the CLI posts to service %q, which this test does not know; add it to cliServices", name, service) - continue - } - m, ok := svc.MethodByName(method) - if !ok { - t.Errorf("%s: no such method on %s", name, svc) - continue - } - if !takesOneObjectOrNothing(m.Type) { - t.Errorf("%s takes %s; a call the CLI posts takes one options struct, after an optional context.Context", - name, m.Type) - } - } -} - -// postedMethods reads the method names out of the package's own source: every -// string literal shaped like one, which is how each call and each method -// constant spells it. -func postedMethods(t *testing.T) map[string]bool { - t.Helper() - entries, err := os.ReadDir(".") - if err != nil { - t.Fatalf("list the package: %v", err) - } - fset := token.NewFileSet() - found := map[string]bool{} - for _, entry := range entries { - name := entry.Name() - if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - file, err := parser.ParseFile(fset, name, nil, 0) - if err != nil { - t.Fatalf("parse %s: %v", name, err) - } - ast.Inspect(file, func(n ast.Node) bool { - lit, ok := n.(*ast.BasicLit) - if !ok || lit.Kind != token.STRING { - return true - } - if value, err := strconv.Unquote(lit.Value); err == nil && rpcMethodName.MatchString(value) { - found[value] = true - } - return true - }) - } - return found -} - -// takesOneObjectOrNothing is whether a method's parameters, receiver and an -// optional leading context.Context aside, are one struct or none. -func takesOneObjectOrNothing(method reflect.Type) bool { - params := make([]reflect.Type, 0, method.NumIn()) - for i := 1; i < method.NumIn(); i++ { - params = append(params, method.In(i)) - } - if len(params) > 0 && params[0] == reflect.TypeFor[context.Context]() { - params = params[1:] - } - return len(params) == 0 || len(params) == 1 && params[0].Kind() == reflect.Struct -} diff --git a/internal/cli/theme.go b/internal/cli/theme.go index c335a1266..2808a137a 100644 --- a/internal/cli/theme.go +++ b/internal/cli/theme.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/themes" ) @@ -36,12 +37,12 @@ func (c *client) theme(args []string) error { func (c *client) listThemes(asJSON bool) error { ctx := context.Background() - var installed []themes.Theme - if err := c.call(ctx, "themes.List", nil, shortCall, &installed); err != nil { + installed, err := callMethod(ctx, c, api.ThemesList, api.None{}, shortCall) + if err != nil { return err } - var selection themes.Selection - if err := c.call(ctx, "themes.Selection", nil, shortCall, &selection); err != nil { + selection, err := callMethod(ctx, c, api.ThemesSelection, api.None{}, shortCall) + if err != nil { return err } rows := []themeRow{{ID: themes.SystemID, Name: "System, the bundled theme the OS asks for", InUse: selection.Theme == themes.SystemID}} @@ -63,12 +64,12 @@ func (c *client) listThemes(asJSON bool) error { } func (c *client) switchTheme(id string, asJSON bool) error { - call := []any{themes.SelectOptions{ID: id}} - if err := c.call(context.Background(), "themes.Select", call, shortCall, nil); err != nil { + opts := api.SelectThemeOptions{ID: id} + if _, err := callMethod(context.Background(), c, api.ThemesSelect, opts, shortCall); err != nil { return err } if asJSON { - return c.emit(themes.SelectOptions{ID: id}) + return c.emit(opts) } fmt.Fprintf(c.stdout, "Switched to %q.\n", id) return nil diff --git a/internal/cli/transport.go b/internal/cli/transport.go index eeb50b6b1..0a6245e9d 100644 --- a/internal/cli/transport.go +++ b/internal/cli/transport.go @@ -12,26 +12,30 @@ import ( "strconv" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/singleton" ) -// call posts one RPC to the lich this session belongs to. Cancelling ctx drops -// the connection, which is how the backend learns its caller stopped waiting. -func (c *client) call(ctx context.Context, method string, args []any, timeout time.Duration, out any) error { +// callMethod posts m to the lich this session belongs to, the one way the CLI +// reaches it, so every call is held to the types api declares. Cancelling ctx +// drops the connection, which is how the backend learns its caller stopped +// waiting. +func callMethod[Req, Resp any](ctx context.Context, c *client, m api.Method[Req, Resp], req Req, timeout time.Duration) (Resp, error) { + var out Resp port, token, err := c.coordinates() if err != nil { - return err + return out, err } - body, err := json.Marshal(args) + body, err := json.Marshal(m.Args(req)) if err != nil { - return fmt.Errorf("encode arguments: %w", err) + return out, fmt.Errorf("encode arguments: %w", err) } httpClient := &http.Client{Timeout: timeout} - status, payload, err := post(ctx, httpClient, endpoint(port, token, method), body) + status, payload, err := post(ctx, httpClient, endpoint(port, token, m.Name), body) if err != nil { - return err + return out, err } // A refused token is not this caller's mistake — the coordinates it was given // can have gone stale under it (see reissued). Retried once, on the same port, @@ -39,22 +43,19 @@ func (c *client) call(ctx context.Context, method string, args []any, timeout ti if status == http.StatusForbidden || status == http.StatusUnauthorized { token, err = c.reissued(port, token) if err != nil { - return err + return out, err } - if status, payload, err = post(ctx, httpClient, endpoint(port, token, method), body); err != nil { - return err + if status, payload, err = post(ctx, httpClient, endpoint(port, token, m.Name), body); err != nil { + return out, err } } if status != http.StatusOK { - return fmt.Errorf("%s", failureOf(payload, status)) + return out, fmt.Errorf("%s", failureOf(payload, status)) } - if out == nil { - return nil + if err := json.Unmarshal(payload, &out); err != nil { + return out, fmt.Errorf("decode the reply: %w", err) } - if err := json.Unmarshal(payload, out); err != nil { - return fmt.Errorf("decode the reply: %w", err) - } - return nil + return out, nil } // post sends one RPC and reads the whole reply. The body is read here rather diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index dfdbb8fed..1c66252aa 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -15,6 +15,7 @@ import ( "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/relay" + "github.com/lichdotdev/lich/internal/routes" "github.com/lichdotdev/lich/internal/rpc" "github.com/lichdotdev/lich/internal/spawn" "github.com/lichdotdev/lich/internal/store" @@ -93,7 +94,7 @@ func wiredLich(t *testing.T) (func(string) string, *wiredTerminal) { dispatcher := rpc.New() // No events sink: these tests are about the wire between the CLI and the // dispatcher, and the window is not on this side of it. - dispatcher.Register("relay", relay.New(wiredSessions{}, term, nil)) + routes.Relay(dispatcher, relay.New(wiredSessions{}, term, nil)) server := httptest.NewServer(dispatcher) t.Cleanup(server.Close) @@ -454,7 +455,7 @@ func wiredSpawn(t *testing.T, git *spawnGit) (func(string) string, *spawnStore, rows := &spawnStore{} term := &spawnTerminal{} dispatcher := rpc.New() - dispatcher.Register("spawn", spawn.New(rows, git, term, nil)) + routes.Spawn(dispatcher, spawn.New(rows, git, term, nil)) server := httptest.NewServer(dispatcher) t.Cleanup(server.Close) @@ -770,7 +771,7 @@ func TestOpenAcrossVersions(t *testing.T) { method, _, _ := strings.Cut(name, " ") term := &spawnTerminal{} dispatcher := rpc.New() - dispatcher.Register("spawn", spawn.New(&spawnStore{}, &spawnGit{}, term, nil)) + routes.Spawn(dispatcher, spawn.New(&spawnStore{}, &spawnGit{}, term, nil)) recorder := httptest.NewRecorder() dispatcher.ServeHTTP(recorder, httptest.NewRequest("POST", "/rpc/"+method, strings.NewReader(body))) @@ -865,7 +866,7 @@ func TestSpawnCallsAcrossVersions(t *testing.T) { t.Run(tc.method, func(t *testing.T) { rows, term := &spawnStore{}, &spawnTerminal{} dispatcher := rpc.New() - dispatcher.Register("spawn", spawn.New(rows, &spawnGit{}, term, nil)) + routes.Spawn(dispatcher, spawn.New(rows, &spawnGit{}, term, nil)) answer := postRaw(t, dispatcher, tc.method, tc.body) if !tc.landed(rows, term, answer) { @@ -883,7 +884,7 @@ func TestSendsAcrossVersions(t *testing.T) { t.Run(method, func(t *testing.T) { term := &wiredTerminal{} dispatcher := rpc.New() - dispatcher.Register("relay", relay.New(wiredSessions{}, term, nil)) + routes.Relay(dispatcher, relay.New(wiredSessions{}, term, nil)) postRaw(t, dispatcher, method, `[{"from":"s1","target":"docs","prompt":"run the tests","waitSeconds":1,"optionFromTheFuture":true}]`) @@ -919,7 +920,7 @@ func TestRelayCallsAcrossVersions(t *testing.T) { t.Run(tc.method, func(t *testing.T) { term := &wiredTerminal{} dispatcher := rpc.New() - dispatcher.Register("relay", relay.New(wiredSessions{}, term, nil)) + routes.Relay(dispatcher, relay.New(wiredSessions{}, term, nil)) if answer := postRaw(t, dispatcher, tc.method, tc.body); !tc.landed(term, answer) { t.Errorf("the call did not land: typed %q, answer %q", term.message(), answer) @@ -934,7 +935,7 @@ func TestRelayCallsAcrossVersions(t *testing.T) { func TestErrandAcrossVersions(t *testing.T) { t.Parallel() dispatcher := rpc.New() - dispatcher.Register("relay", relay.New(wiredSessions{}, &wiredTerminal{}, nil)) + routes.Relay(dispatcher, relay.New(wiredSessions{}, &wiredTerminal{}, nil)) var sent relay.Result answer := postRaw(t, dispatcher, "relay.Send", `[{"from":"s1","target":"docs","prompt":"run the tests","waitSeconds":1}]`) diff --git a/internal/codexprofile/login.go b/internal/codexprofile/login.go index 247b0bd3a..8d5117348 100644 --- a/internal/codexprofile/login.go +++ b/internal/codexprofile/login.go @@ -7,10 +7,10 @@ import ( "io" "regexp" "time" -) -// LoginEventName is the event a login reports its progress on. -const LoginEventName = "codex-profile-login" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) // loginTimeout bounds a login lich started: a browser tab the user walked away // from must not leave a CLI waiting for good. @@ -21,23 +21,7 @@ const loginTimeout = 10 * time.Minute // The local callback server's own address is http, which this does not match. var loginURL = regexp.MustCompile(`https://\S+`) -// LoginEvent is one step of a login. Output carries what the CLI printed, with -// URL set when that text holds the sign-in address. Authenticating says the CLI -// is through with the browser and lich is reading whether the login took. The -// last event has Done set: -// LoggedIn is what `codex login status` answered afterwards, Error why the login -// failed or was cancelled, and PluginError why the lich plugin did not install. -type LoginEvent struct { - Profile string `json:"profile"` - Output string `json:"output,omitempty"` - URL string `json:"url,omitempty"` - Authenticating bool `json:"authenticating,omitempty"` - Done bool `json:"done,omitempty"` - LoggedIn bool `json:"loggedIn,omitempty"` - Cancelled bool `json:"cancelled,omitempty"` - Error string `json:"error,omitempty"` - PluginError string `json:"pluginError,omitempty"` -} +type LoginEvent = api.ProfileLogin type login struct { cancel context.CancelFunc @@ -93,7 +77,7 @@ func (s *Service) relayOutput(slug string, out io.Reader) { n, err := out.Read(buf) if n > 0 { text := string(buf[:n]) - s.emit(LoginEventName, LoginEvent{Profile: slug, Output: text, URL: loginURL.FindString(text)}) + events.Send(s.emit, api.CodexProfileLoginEvent, LoginEvent{Profile: slug, Output: text, URL: loginURL.FindString(text)}) } if err != nil { return @@ -114,14 +98,14 @@ func (s *Service) finishLogin(ctx context.Context, p Profile, run *login, waitEr case errors.Is(ended, context.Canceled): done.Cancelled = true done.Error = "login cancelled" - s.emit(LoginEventName, done) + events.Send(s.emit, api.CodexProfileLoginEvent, done) return case errors.Is(ended, context.DeadlineExceeded): done.Error = fmt.Sprintf("login timed out after %s", loginTimeout) - s.emit(LoginEventName, done) + events.Send(s.emit, api.CodexProfileLoginEvent, done) return } - s.emit(LoginEventName, LoginEvent{Profile: p.Slug, Authenticating: true}) + events.Send(s.emit, api.CodexProfileLoginEvent, LoginEvent{Profile: p.Slug, Authenticating: true}) loggedIn, err := s.loggedIn(p) switch { case err != nil: @@ -135,7 +119,7 @@ func (s *Service) finishLogin(ctx context.Context, p Profile, run *login, waitEr done.PluginError = err.Error() } } - s.emit(LoginEventName, done) + events.Send(s.emit, api.CodexProfileLoginEvent, done) } // CancelLogin ends a login lich started; its last event says it was cancelled. diff --git a/internal/codexprofile/service.go b/internal/codexprofile/service.go index e4d0e96b7..ff92f913f 100644 --- a/internal/codexprofile/service.go +++ b/internal/codexprofile/service.go @@ -13,9 +13,12 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/winexec" + + "github.com/lichdotdev/lich/internal/events" ) const ( @@ -92,19 +95,7 @@ func (s *Service) SetAccount(account func(slug string) (email, plan string)) { s.account = account } -// Info is a profile as the Settings screen shows it: whether anybody is logged -// in there, the account and plan when they could be read, and how many plugins -// it has. -type Info struct { - Profile - LoggedIn bool `json:"loggedIn"` - Email string `json:"email,omitempty"` - Plan string `json:"plan,omitempty"` - Plugins int `json:"plugins"` - // StatusError is why `codex login status` gave no answer, in its own words; - // LoggedIn is then false for want of one. - StatusError string `json:"statusError,omitempty"` -} +type Info = api.ProfileInfo // Profiles lists every profile with its login state. The statuses are asked for // at once, one CLI each. @@ -165,7 +156,7 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er if err != nil { return p, err } - s.emit(profilestore.ChangedEvent, profilestore.Changed{Provider: store.Provider, Profile: p}) + events.Send(s.emit, api.ProfileChangedEvent, profilestore.Changed{Provider: store.Provider, Profile: p}) return p, nil } @@ -189,12 +180,7 @@ func (s *Service) DeleteProfile(slug string) error { return nil } -// Plugin is one plugin a profile has installed. -type Plugin struct { - Key string `json:"key"` - Name string `json:"name"` - Marketplace string `json:"marketplace"` -} +type Plugin = api.ProfilePlugin // Plugins lists the plugins a profile has installed, the ones a port can carry // to another profile, in its config.toml's order. The lich plugin is not among @@ -219,13 +205,7 @@ func (s *Service) Plugins(slug string) ([]Plugin, error) { return out, nil } -// PluginResult is one plugin's install, with Codex's own words when it failed: -// a marketplace the profile cannot reach, a plugin it no longer lists. -type PluginResult struct { - Key string `json:"key"` - OK bool `json:"ok"` - Message string `json:"message,omitempty"` -} +type PluginResult = api.ProfilePluginResult // InstallPlugins installs each plugin@marketplace into a profile through // Codex's own `plugin add`, run as that profile so Codex writes its own paths. diff --git a/internal/codexprofile/service_test.go b/internal/codexprofile/service_test.go index b8b858be4..da53a594d 100644 --- a/internal/codexprofile/service_test.go +++ b/internal/codexprofile/service_test.go @@ -10,6 +10,8 @@ import ( "sync" "testing" "time" + + "github.com/lichdotdev/lich/api" ) // fakeCodexGuard turns the test binary into a stand-in for the Codex CLI @@ -100,7 +102,7 @@ func newRecorder() *recorder { return &recorder{done: make(chan LoginEvent, 1)} func (r *recorder) emit(name string, data any) { e, ok := data.(LoginEvent) - if name != LoginEventName || !ok { + if name != api.CodexProfileLoginEvent.Name || !ok { return } r.mu.Lock() diff --git a/internal/codexprofile/update_test.go b/internal/codexprofile/update_test.go index c9d527005..e530f3529 100644 --- a/internal/codexprofile/update_test.go +++ b/internal/codexprofile/update_test.go @@ -3,6 +3,7 @@ package codexprofile import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" ) @@ -10,7 +11,7 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { machine(t) var got []profilestore.Changed s := New(fakeBins{}, func(name string, data any) { - if c, ok := data.(profilestore.Changed); ok && name == profilestore.ChangedEvent { + if c, ok := data.(profilestore.Changed); ok && name == api.ProfileChangedEvent.Name { got = append(got, c) } }) diff --git a/internal/drop/drop.go b/internal/drop/drop.go index c7573159e..e22d4ee68 100644 --- a/internal/drop/drop.go +++ b/internal/drop/drop.go @@ -35,6 +35,7 @@ import ( "time" "unicode" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/sandbox" @@ -171,14 +172,7 @@ func copyNotice(lang prompt.Lang, name string) string { // about why it opened. const attachTitle = "Attach File" -// Attachment is what the picker produced: the path to write at the prompt, and -// the line that goes under it when that path is a copy's (copyNotice), empty -// when the session opens the file where it lies. Both empty for a cancelled -// dialog. -type Attachment struct { - Path string `json:"path"` - Notice string `json:"notice"` -} +type Attachment = api.DropAttachment // Attach opens the file chooser and answers with a path the session can // actually open: the file's own where the session reaches it, a copy's inside diff --git a/internal/events/send.go b/internal/events/send.go new file mode 100644 index 000000000..eda02d8a5 --- /dev/null +++ b/internal/events/send.go @@ -0,0 +1,14 @@ +package events + +import "github.com/lichdotdev/lich/api" + +// Send pushes payload as e through emit: the hub's Emit, or the copy of it a +// service was handed. The compiler holds payload to the type api declares for +// e; a None payload goes out as no data at all. +func Send[T any](emit func(name string, data any), e api.Event[T], payload T) { + if _, empty := any(payload).(api.None); empty { + emit(e.Name, nil) + return + } + emit(e.Name, payload) +} diff --git a/internal/events/send_test.go b/internal/events/send_test.go new file mode 100644 index 000000000..4aa60d6b7 --- /dev/null +++ b/internal/events/send_test.go @@ -0,0 +1,23 @@ +package events + +import ( + "testing" + + "github.com/lichdotdev/lich/api" +) + +func TestSendNamesTheEventAndCarriesItsPayload(t *testing.T) { + var name string + var data any + emit := func(n string, d any) { name, data = n, d } + + Send(emit, api.SessionTouchedEvent, api.SessionTouched{ID: "s1"}) + if name != "session-touched" || data != (api.SessionTouched{ID: "s1"}) { + t.Errorf("sent %s %v, want session-touched with the session", name, data) + } + + Send(emit, api.WindowRaiseEvent, api.None{}) + if name != "window-raise" || data != nil { + t.Errorf("sent %s %v, want window-raise with no data", name, data) + } +} diff --git a/internal/ompprofile/service.go b/internal/ompprofile/service.go index 17223c068..147b6d276 100644 --- a/internal/ompprofile/service.go +++ b/internal/ompprofile/service.go @@ -12,9 +12,12 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/winexec" + + "github.com/lichdotdev/lich/internal/events" ) const ( @@ -56,21 +59,7 @@ func (s *Service) SetSessions(inUse func(slug string) int, forget func(slug stri s.inUse, s.forget = inUse, forget } -// Info is a profile as the Settings screen shows it: whether any account is -// logged in there, and which. -type Info struct { - Profile - LoggedIn bool `json:"loggedIn"` - // Email is the address of each OAuth account, comma separated. omp keeps - // several logins per profile, one per provider or more, so there is no one - // account to name. - Email string `json:"email,omitempty"` - // Providers are the providers omp holds an OAuth login for here. - Providers []string `json:"providers,omitempty"` - // StatusError is why `omp usage accounts` gave no answer, in its own words; - // LoggedIn is then false for want of one. - StatusError string `json:"statusError,omitempty"` -} +type Info = api.ProfileInfo // accounts is the JSON `omp usage accounts --json` prints (measured on 18.8.9): // one row per OAuth login in the profile, API keys left out. identityKey reads @@ -146,7 +135,7 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er if err != nil { return p, err } - s.emit(profilestore.ChangedEvent, profilestore.Changed{Provider: store.Provider, Profile: p}) + events.Send(s.emit, api.ProfileChangedEvent, profilestore.Changed{Provider: store.Provider, Profile: p}) return p, nil } diff --git a/internal/ompprofile/update_test.go b/internal/ompprofile/update_test.go index a8adf33df..c7b937190 100644 --- a/internal/ompprofile/update_test.go +++ b/internal/ompprofile/update_test.go @@ -3,6 +3,7 @@ package ompprofile import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" ) @@ -10,7 +11,7 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { machine(t) var got []profilestore.Changed s := New(fakeBins{}, func(name string, data any) { - if c, ok := data.(profilestore.Changed); ok && name == profilestore.ChangedEvent { + if c, ok := data.(profilestore.Changed); ok && name == api.ProfileChangedEvent.Name { got = append(got, c) } }) diff --git a/internal/patchnotes/patchnotes.go b/internal/patchnotes/patchnotes.go index aec2d9fa5..1575e7772 100644 --- a/internal/patchnotes/patchnotes.go +++ b/internal/patchnotes/patchnotes.go @@ -30,29 +30,15 @@ import ( "errors" "io/fs" "strings" + + "github.com/lichdotdev/lich/api" ) -// Group is one "### Added / Changed / Fixed" block of a release's notes. -type Group struct { - Label string `json:"label"` - // Items keep their markdown bold/code markers; the window renders them. - Items []string `json:"items"` -} +type Group = api.PatchNotesGroup -// Highlight is one GitHub alert block ("> [!IMPORTANT]") written between a -// release's heading and its first group. Kind is the alert type, lowercased. -type Highlight struct { - Kind string `json:"kind"` - // Text keeps its markdown bold/code markers; the window renders them. - Text string `json:"text"` -} +type Highlight = api.PatchNotesHighlight -// Notes is a release's changelog section, ready for the window to render. -type Notes struct { - Version string `json:"version"` - Highlights []Highlight `json:"highlights"` - Groups []Group `json:"groups"` -} +type Notes = api.PatchNotes // Service serves the notes for one fixed version — the running build. type Service struct { diff --git a/internal/profilestore/look.go b/internal/profilestore/look.go index ef605923f..3bce75fe0 100644 --- a/internal/profilestore/look.go +++ b/internal/profilestore/look.go @@ -15,29 +15,13 @@ import ( xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" -) -// ChangedEvent is the event a profile's update emits once it is written: its -// name, color or image changed. -const ChangedEvent = "profile-changed" + "github.com/lichdotdev/lich/api" +) -// Changed is ChangedEvent's payload. Provider is the Spec's. -type Changed struct { - Provider string `json:"provider"` - Profile Profile `json:"profile"` -} +type Changed = api.ProfileChanged -// Update is a change to how a profile is shown. A field left nil keeps what the -// profile has; Name keeps it when empty. -type Update struct { - Name string `json:"name,omitempty"` - // Color is a card color's name; "" goes back to the color the window - // derives from the slug. The window draws a name it does not know the same - // way, so it is stored as given. - Color *string `json:"color,omitempty"` - // Image is a file to make the profile's image from; "" removes the image. - Image *string `json:"image,omitempty"` -} +type Update = api.ProfileUpdate const ( // avatarEdge is the side of the square a profile image is stored at: the diff --git a/internal/profilestore/store.go b/internal/profilestore/store.go index bca0e695e..b1f94bd8e 100644 --- a/internal/profilestore/store.go +++ b/internal/profilestore/store.go @@ -26,6 +26,8 @@ import ( "strconv" "strings" "time" + + "github.com/lichdotdev/lich/api" ) // Personal is the slug of the default profile. It is reserved: no profile lich @@ -118,21 +120,7 @@ type metaFile struct { Copied []string `json:"copied,omitempty"` } -// Profile is one login lich knows about. -type Profile struct { - Slug string `json:"slug"` - Name string `json:"name"` - Dir string `json:"dir"` - Personal bool `json:"personal"` - // Copied names the shared entries (a settings file, say) this profile holds - // as a copy, so edits made in one account no longer reach the other. - Copied []string `json:"copied,omitempty"` - // Color is the card color the profile is shown in, "" for the one the - // window derives from the slug. - Color string `json:"color,omitempty"` - // Image is the profile's picture as a PNG, avatarEdge square. - Image []byte `json:"image,omitempty"` -} +type Profile = api.Profile // IsPersonal reports whether slug names the default profile. Empty does too: it // is what every session row born before profiles carries. diff --git a/internal/project/basestatus.go b/internal/project/basestatus.go index b6d8510dc..ea7bd94f8 100644 --- a/internal/project/basestatus.go +++ b/internal/project/basestatus.go @@ -9,17 +9,11 @@ import ( "strings" "sync" "time" + + "github.com/lichdotdev/lich/api" ) -// BaseStatus is where a checkout stands against the branch it will merge into: -// how far that branch has moved underneath it, and which files a merge would -// collide on right now. It is what turns "the merge button refused" into -// something a parallel worktree knows before anyone clicks it. -type BaseStatus struct { - Base string `json:"base"` // the branch merged into, "origin/main" form - Behind int `json:"behind"` // commits the base has that this checkout does not - Conflicts []string `json:"conflicts"` -} +type BaseStatus = api.BaseStatus // baseFetchInterval throttles the background fetch behind the readout. Nothing // else in lich fetches after a worktree is created, and a branch lands on the diff --git a/internal/project/branchrules.go b/internal/project/branchrules.go index 4f683e0fb..0e1729edb 100644 --- a/internal/project/branchrules.go +++ b/internal/project/branchrules.go @@ -4,47 +4,13 @@ import ( "encoding/json" "fmt" "net/url" + + "github.com/lichdotdev/lich/api" ) -// BranchRules is what a repository's rulesets say about merging into one branch. GitHub's -// pull request fields do not carry it: `mergeStateStatus` answers whether this -// merge can happen, never which methods the branch would accept, so a Merge -// menu built without this offers buttons the ruleset refuses — and gh's -// refusal arrives after the click, as one flat sentence (gherror.go). -// -// The endpoint answers for a branch, not a pull request, which is why this is -// its own read: every pull request onto the same base shares one answer. -type BranchRules struct { - // AllowedMergeMethods is squash|merge|rebase, in GitHub's own spelling. - // Empty means the branch has no rule about it and all three stand — which - // is also what a repository with no rulesets at all reports. - AllowedMergeMethods []string `json:"allowedMergeMethods"` - // CommitPatterns are the rules that read a commit's own text. Empty is the - // common case and means only "no rule of this kind", never "nothing blocks". - CommitPatterns []CommitPattern `json:"commitPatterns"` - // ViewerCanBypass is whether this account administers the repository, and so - // may merge past the rules above. It is the one answer here that must fail - // *closed*: an unreadable one hides an action rather than offering one that - // cannot work, because a bypass offered to an account that has none reads as - // a broken button, not as a permission it never had. - ViewerCanBypass bool `json:"viewerCanBypass"` -} +type BranchRules = api.BranchRules -// CommitPattern is one ruleset rule that tests the text of a commit rather than -// the state of a pull request. It earns its own field because it is the one -// class of rule nothing on a pull request hints at: the checks pass, the review -// lands, no conflict exists, and GitHub still answers BLOCKED — because the -// message the merge *would* write breaks a pattern nobody can see from here. -type CommitPattern struct { - // Target is the part the rule reads, already in the words the screen uses: - // "message", "author email", "committer email". - Target string `json:"target"` - // Operator is GitHub's own: regex | starts_with | ends_with | contains. - Operator string `json:"operator"` - Pattern string `json:"pattern"` - // Negate flips the test — the pattern is then what the text must *not* be. - Negate bool `json:"negate"` -} +type CommitPattern = api.CommitPattern // commitPatternTargets maps the rule types that test commit text onto the word // the screen puts in the sentence. GitHub has pattern rules for branch and tag diff --git a/internal/project/commitidentity.go b/internal/project/commitidentity.go index 05f94696e..9968534ae 100644 --- a/internal/project/commitidentity.go +++ b/internal/project/commitidentity.go @@ -1,19 +1,10 @@ package project +import "github.com/lichdotdev/lich/api" + import "strings" -// CommitIdentity is the name and email the next commit in a checkout would be -// authored with — the fact the project's gh account (ghaccount.go) does not -// carry, since that one only governs what lich asks gh. Local says the value -// comes from the checkout's own config rather than the global one. -// -// The zero value is a legitimate answer, not a failure: a checkout with no -// identity configured is one of the states worth showing. -type CommitIdentity struct { - Name string `json:"name"` - Email string `json:"email"` - Local bool `json:"local"` -} +type CommitIdentity = api.CommitIdentity // CommitIdentity reads the identity git itself resolves in the checkout at // path, so a repository-local override wins over the global config exactly as diff --git a/internal/project/freshness.go b/internal/project/freshness.go index 8be30a668..1024da687 100644 --- a/internal/project/freshness.go +++ b/internal/project/freshness.go @@ -4,20 +4,11 @@ import ( "fmt" "strconv" "strings" + + "github.com/lichdotdev/lich/api" ) -// BaseFreshness is where a local branch stands against its upstream, read -// right after fetching it: what the new-worktree dialog needs to start a -// worktree from the newer of the two. Upstream is "" for a branch that tracks -// nothing on a remote, and every other field is then zero. -type BaseFreshness struct { - Upstream string `json:"upstream"` // "origin/main" form - Ahead int `json:"ahead"` - Behind int `json:"behind"` - // FetchError is why the fetch failed, "" when it did not. The counts are - // then against whatever an earlier fetch left. - FetchError string `json:"fetchError,omitempty"` -} +type BaseFreshness = api.BaseFreshness // BaseFreshness fetches branch's upstream and counts the commits each side // has that the other lacks. A fetch that fails is part of the answer, never an diff --git a/internal/project/issue.go b/internal/project/issue.go index 2f93a5aff..a166a4d4a 100644 --- a/internal/project/issue.go +++ b/internal/project/issue.go @@ -5,6 +5,8 @@ import ( "fmt" "strconv" "strings" + + "github.com/lichdotdev/lich/api" ) // An issue read is one round-trip like a pull request read, and shares its @@ -17,14 +19,7 @@ const issueReadTimeout = prReadTimeout // issue's URL travels with it, so the cut costs nothing but a click. const maxIssueBody = 4000 -// Issue is the little of a GitHub issue the New worktree dialog needs: what to -// name the branch after, and what to tell the session it opens. -type Issue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - URL string `json:"url"` -} +type Issue = api.Issue // Issue resolves one GitHub issue through gh, as the account the project // selected. The title names the branch the dialog is about to create, and the diff --git a/internal/project/pr.go b/internal/project/pr.go index 57b082814..76bc1a9b0 100644 --- a/internal/project/pr.go +++ b/internal/project/pr.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "time" + + "github.com/lichdotdev/lich/api" ) // gh network calls are capped so a slow forge or a hung auth prompt never @@ -82,73 +84,9 @@ func prArgs(verb string, number int, rest ...string) []string { return append(args, rest...) } -// PRDetail is the full view of a branch's open pull request — richer than the -// footer badge's PullRequest — driving the dock's Pulls panel: the title, body, -// CI rollup and mergeability gate the merge affordance. gh's state does not -// travel with it: a detail exists only when the PR is open (parsePRDetail). -type PRDetail struct { - Number int `json:"number"` - URL string `json:"url"` - Title string `json:"title"` - Body string `json:"body"` - // Author is the login of whoever opened it, or their display name when gh - // reports no login. GitHub refuses a review request addressed to them, which - // is what keeps them out of the picker. - Author string `json:"author"` - // AuthorLogin is the same account as Author, minus the fallback to a display - // name — the value a commit's login is compared against. Author cannot serve: - // a name matches no login, so a PR whose author gh reports without one would - // read as every commit landing under the wrong account. - AuthorLogin string `json:"authorLogin"` - // State is gh's OPEN | CLOSED | MERGED. Only a number-addressed lookup can - // return a non-OPEN one; the branch lookup still gates them out. - State string `json:"state"` - IsDraft bool `json:"isDraft"` - Mergeable string `json:"mergeable"` // gh: MERGEABLE | CONFLICTING | UNKNOWN - // MergeStateStatus is GitHub's own answer to "can this merge right now" — - // CLEAN | UNSTABLE | BLOCKED | BEHIND | DIRTY | DRAFT | HAS_HOOKS | UNKNOWN. - // Mergeable alone cannot gate the button: it says nothing about a required - // review or a required check, and it reads UNKNOWN while GitHub recomputes - // after a push. - MergeStateStatus string `json:"mergeStateStatus"` - // ReviewDecision is gh's aggregate verdict — APPROVED | CHANGES_REQUESTED | - // REVIEW_REQUIRED — and "" where the repository requires no review. Who - // reviewed is a different field (latestReviews) this does not carry. - ReviewDecision string `json:"reviewDecision"` - // Reviewers is who was asked and who answered, in one list (prreviewers.go). - Reviewers []PRReviewer `json:"reviewers"` - BaseRefName string `json:"baseRefName"` - HeadRefName string `json:"headRefName"` - ChangedFiles int `json:"changedFiles"` - Checks ChecksRollup `json:"checks"` - CheckRuns []CheckItem `json:"checkRuns"` - Commits []PRCommit `json:"commits"` - // IsCrossRepository marks a head branch that lives on a fork. - IsCrossRepository bool `json:"isCrossRepository"` - // MaintainerCanModify is GitHub's "allow edits by maintainers" on a fork PR, - // and the whole of what decides whether a session can be opened on one: with - // it a push back to the fork's branch is authorised, without it there is - // nowhere for an agent's commits to go (CreateWorktreeFromPR). It is false on - // a same-repository PR, where nothing needs the permission. - MaintainerCanModify bool `json:"maintainerCanModify"` -} +type PRDetail = api.PRDetail -// PRCommit is one commit the pull request would land, split the way git itself -// splits a message: the subject line, then the body (empty for a one-liner). -// -// Login and Name are kept apart because only one of them is evidence: Name is -// free text, Login is the account GitHub resolved the author's email to against -// every account's verified addresses — an answer lich cannot compute, and one -// whose "" means the email belongs to nobody rather than "unknown". -type PRCommit struct { - OID string `json:"oid"` - Headline string `json:"headline"` - Body string `json:"body"` - Login string `json:"login"` - Name string `json:"name"` - Email string `json:"email"` - Date string `json:"date"` // gh's ISO committedDate -} +type PRCommit = api.PRCommit // ghPRView mirrors the requested gh payload; statusCheckRollup is reduced to // ChecksRollup inside parsePRDetail and never leaves this package raw. @@ -391,30 +329,7 @@ const ( // the window can never widen into an arbitrary flag. var prListStates = map[string]bool{"open": true, "closed": true, "merged": true, "all": true} -// PRSummary is one row of the repository's open pull requests — what the list -// column needs to render and rank a PR before anything is selected. The full -// view behind a row is PRDetail, fetched per PR. -type PRSummary struct { - Number int `json:"number"` - Title string `json:"title"` - Author string `json:"author"` // login, or the display name when gh reports no login - State string `json:"state"` // gh: OPEN | CLOSED | MERGED - IsDraft bool `json:"isDraft"` - // ReviewDecision is gh's APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED, - // and "" for a repository that requires no review. - ReviewDecision string `json:"reviewDecision"` - HeadRefName string `json:"headRefName"` - // IsCrossRepository marks a PR whose head branch lives on a fork. lich can - // check such a branch out, but the commits would have nowhere to push, so - // the session flow refuses it (CreateWorktreeFromPR) and the button reads - // as blocked rather than failing on click. - IsCrossRepository bool `json:"isCrossRepository"` - UpdatedAt string `json:"updatedAt"` // gh's ISO timestamp - Checks ChecksRollup `json:"checks"` - // Threads is counted only on a CHANGES_REQUESTED row (prlistthreads.go); - // every other row carries a zero tally. - Threads ThreadTally `json:"threads"` -} +type PRSummary = api.PRSummary // ghPRListItem mirrors one gh `pr list --json` entry; the rollup is reduced to // ChecksRollup and the author object flattened before either leaves this package. diff --git a/internal/project/prchanged.go b/internal/project/prchanged.go index d2d8b4e23..51ebee33c 100644 --- a/internal/project/prchanged.go +++ b/internal/project/prchanged.go @@ -1,21 +1,15 @@ package project -import "time" +import ( + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) -// PRChangedEvent is what a pull request write through lich reports once GitHub -// took it, so every window's pull request store reads it again instead of the -// screen that made the change telling the others. -const PRChangedEvent = "pull-request-changed" +import "time" -// PRChanged names the checkout the write ran in and the pull request it -// addressed; Number is 0 where the write addresses a thread or a review rather -// than a number (the checkout's repository is then all that is known). -type PRChanged struct { - Path string `json:"path"` - Number int `json:"number"` -} +type PRChanged = api.PRChanged -// SetEmit wires where pull request writes report (PRChangedEvent). +// SetEmit wires where pull request writes report (api.PullRequestChangedEvent). func (s *Service) SetEmit(emit func(name string, data any)) { s.emit = emit } // writePR runs a gh write for the pull request and reports it once it landed. @@ -24,7 +18,7 @@ func (s *Service) writePR(timeout time.Duration, path string, number int, args . return err } if s.emit != nil { - s.emit(PRChangedEvent, PRChanged{Path: path, Number: number}) + events.Send(s.emit, api.PullRequestChangedEvent, PRChanged{Path: path, Number: number}) } return nil } diff --git a/internal/project/prchanged_test.go b/internal/project/prchanged_test.go index 8aeef4e8e..43c651ec6 100644 --- a/internal/project/prchanged_test.go +++ b/internal/project/prchanged_test.go @@ -3,6 +3,8 @@ package project import ( "errors" "testing" + + "github.com/lichdotdev/lich/api" ) type changeLog struct { @@ -39,8 +41,8 @@ func TestPullRequestWritesAnnounceTheChange(t *testing.T) { if err := write(s); err != nil { t.Fatalf("unexpected error: %v", err) } - if len(log.names) != 1 || log.names[0] != PRChangedEvent { - t.Fatalf("events = %v, want one %q", log.names, PRChangedEvent) + if len(log.names) != 1 || log.names[0] != api.PullRequestChangedEvent.Name { + t.Fatalf("events = %v, want one %q", log.names, api.PullRequestChangedEvent.Name) } if log.data[0].Path != "/repo" { t.Errorf("path = %q, want /repo", log.data[0].Path) diff --git a/internal/project/prchecks.go b/internal/project/prchecks.go index 95dd2b8a0..2bdefed82 100644 --- a/internal/project/prchecks.go +++ b/internal/project/prchecks.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "sort" + + "github.com/lichdotdev/lich/api" ) // gh reports a pull request's CI as a mixed array of two unrelated shapes. This @@ -11,30 +13,9 @@ import ( // wants — a header count and a ranked list — and nothing else in the package // needs to know either shape exists. -// ChecksRollup collapses gh's statusCheckRollup array into the counts the panel -// shows. Total is every check, so "all green" is Passed == Total && Total > 0. -type ChecksRollup struct { - Passed int `json:"passed"` - Failed int `json:"failed"` - Pending int `json:"pending"` - Total int `json:"total"` -} +type ChecksRollup = api.ChecksRollup -// CheckItem is one check, flattened from gh's two rollup shapes into what the -// Checks tab lists. State is this package's own passed|failed|pending, the same -// verdict ChecksRollup counts. The timestamps are gh's ISO strings, left for the -// window to turn into a duration. -type CheckItem struct { - Name string `json:"name"` - State string `json:"state"` - Description string `json:"description"` // workflow name, or the status context's own line - URL string `json:"url"` // where to read the run; "" when gh reports none - StartedAt string `json:"startedAt"` - CompletedAt string `json:"completedAt"` - // Required is GitHub's own answer to "does this check gate the merge", the - // same verdict its checks list labels Required. - Required bool `json:"required"` -} +type CheckItem = api.CheckItem // checkItem is one statusCheckRollup entry. gh emits two shapes: a CheckRun // (Actions/apps) carries name/status/conclusion/detailsUrl; a StatusContext diff --git a/internal/project/prlistthreads.go b/internal/project/prlistthreads.go index e812094e4..3c3d4fb50 100644 --- a/internal/project/prlistthreads.go +++ b/internal/project/prlistthreads.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/lichdotdev/lich/api" ) // GitHub keeps a CHANGES_REQUESTED verdict until the reviewer approves or the @@ -16,11 +18,7 @@ import ( // Ceiling: the last 100 threads of each pull request, the conversation's own // cap (prthreads.go). -// ThreadTally is how many of a pull request's review threads are still open. -type ThreadTally struct { - Open int `json:"open"` - Total int `json:"total"` -} +type ThreadTally = api.ThreadTally // ghThreadTallies mirrors the aliased payload: one key per pull request asked. type ghThreadTallies struct { diff --git a/internal/project/project.go b/internal/project/project.go index 3dbfba78d..26b9b2029 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -15,14 +15,11 @@ import ( "strconv" "strings" "time" + + "github.com/lichdotdev/lich/api" ) -// Project identifies an opened project directory. -type Project struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` -} +type Project = api.OpenedProject // Picker shows native file/directory choosers — zenity in production // (picker.go), fakes in tests. Returns "" on user cancel. @@ -140,14 +137,7 @@ func (s *Service) Branch(path string) string { return strings.TrimSpace(out) } -// PullRequest identifies the open GitHub pull request for a work tree's current -// branch, as reported by the gh CLI. State is one of gh's OPEN, CLOSED, MERGED; -// only OPEN reaches the window (see parsePullRequest). -type PullRequest struct { - Number int `json:"number"` - URL string `json:"url"` - State string `json:"state"` -} +type PullRequest = api.PullRequest // prLookupTimeout caps the gh network call so a slow forge or hung auth prompt // never stalls the footer poll. @@ -239,18 +229,7 @@ func (s *Service) PickSaveFile(title, defaultName string) (string, error) { return path, nil } -// DiffStats is what one status read says about a work tree: its uncommitted -// changes, and the two things they sit on. Head is the commit — the window -// watches it to notice a commit the way it watches Files/Added/Deleted to -// notice an edit — and Branch is the branch, carried here because git answers -// both in the same call the counts come out of. -type DiffStats struct { - Files int `json:"files"` - Added int `json:"added"` - Deleted int `json:"deleted"` - Head string `json:"head"` - Branch string `json:"branch"` -} +type DiffStats = api.DiffStats // Diff returns the dirty-file count (modified + untracked), the added/deleted // line totals against HEAD, the HEAD commit itself and the branch it is on. A diff --git a/internal/project/prreview.go b/internal/project/prreview.go index 0d44ce1a0..decabe5e7 100644 --- a/internal/project/prreview.go +++ b/internal/project/prreview.go @@ -7,6 +7,8 @@ import ( "os" "strconv" "strings" + + "github.com/lichdotdev/lich/api" ) // The three writes a review takes, and why each one is the API it is: @@ -37,18 +39,7 @@ var reviewEvents = map[string]string{ // value sent by the window can never reach GitHub as something else. var reviewSides = map[string]bool{"RIGHT": true, "LEFT": true} -// ReviewComment is one line comment of a review being submitted. Line is the -// last line it covers and StartLine the first (0 for a single-line comment) — -// GitHub's own convention, kept here so a selection crosses unchanged. -type ReviewComment struct { - Path string `json:"path"` - Line int `json:"line"` - StartLine int `json:"startLine"` - // Side is RIGHT (the new file) or LEFT (a deleted line). Empty means RIGHT, - // which is where all but a deletion comment goes. - Side string `json:"side"` - Body string `json:"body"` -} +type ReviewComment = api.ReviewComment // ghReviewPayload is GitHub's REST body for a submitted review. Its field names // are snake_case and its comment shape is not lich's, which is the whole reason diff --git a/internal/project/prreviewers.go b/internal/project/prreviewers.go index c7a086628..60b8af769 100644 --- a/internal/project/prreviewers.go +++ b/internal/project/prreviewers.go @@ -1,16 +1,8 @@ package project -// PRReviewer is one entry of a pull request's review roster: somebody who was -// asked and has not answered yet, or somebody who has. -type PRReviewer struct { - // Login is the account's login, or a team's slug — which is not what gh takes - // to add or remove one (that is org/slug), so a team is shown and not edited. - Login string `json:"login"` - IsTeam bool `json:"isTeam"` - // State is "" while the review is still owed, else gh's APPROVED | - // CHANGES_REQUESTED | COMMENTED | DISMISSED. - State string `json:"state"` -} +import "github.com/lichdotdev/lich/api" + +type PRReviewer = api.PRReviewer // ghReviewRequest is one entry of gh's reviewRequests: a User carries a login, a // Team a slug, and __typename is the only thing that says which. diff --git a/internal/project/prthreads.go b/internal/project/prthreads.go index 4ee9a0cca..19a40598a 100644 --- a/internal/project/prthreads.go +++ b/internal/project/prthreads.go @@ -3,6 +3,8 @@ package project import ( "encoding/json" "fmt" + + "github.com/lichdotdev/lich/api" ) // A pull request's conversation is the one part of it gh's own `pr view` cannot @@ -33,85 +35,15 @@ const prConversationQuery = `query($owner:String!,$repo:String!,$number:Int!){ } }` -// PRConversation is everything said about a pull request: the reviews and their -// verdicts, the comments left on the pull request itself, and the threads -// anchored to a file and line. The Pulls screen renders the anchored ones inside -// the diff and the rest in its Conversation tab. -type PRConversation struct { - // HeadRefOID is the commit a new review comment is filed against. GitHub - // defaults to the latest commit, but naming it means a review submitted - // seconds after a push cannot silently land on lines nobody read. - HeadRefOID string `json:"headRefOid"` - Reviews []PRReview `json:"reviews"` - // Comments are the pull request's own comments — the ones with no file and - // no line, which GitHub files as issue comments. - Comments []PRComment `json:"comments"` - Threads []PRReviewThread `json:"threads"` -} +type PRConversation = api.PRConversation -// PRReview is one submitted review: its verdict, and whatever was written in the -// summary box above the line comments. -type PRReview struct { - // ID is the GraphQL node id, which is what dismissing the review takes. - ID string `json:"id"` - // DatabaseID is the REST id, the address its summary is rewritten at. - DatabaseID int64 `json:"databaseId"` - Author string `json:"author"` - // State is gh's APPROVED | CHANGES_REQUESTED | COMMENTED | DISMISSED. - State string `json:"state"` - Body string `json:"body"` - Date string `json:"date"` - CanEdit bool `json:"canEdit"` -} +type PRReview = api.PRReview -// PRComment is one comment on the pull request itself. -type PRComment struct { - // ID is the REST database id, the address an edit or a delete takes. - ID int64 `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - Date string `json:"date"` - CanEdit bool `json:"canEdit"` - CanDelete bool `json:"canDelete"` -} +type PRComment = api.PRComment -// PRReviewThread is one conversation anchored to a line of the diff. -type PRReviewThread struct { - // ID is the GraphQL node id, which is what resolving a thread takes. It is - // not the number a reply is addressed to — that is a comment's own ID. - ID string `json:"id"` - Path string `json:"path"` - // Line is the anchor in the file the thread's side names, and StartLine the - // first line of a multi-line thread (0 when it covers one line). A thread - // GitHub could not re-anchor keeps its original line and is marked outdated, - // which is what sends it to the Conversation tab instead of the diff. - Line int `json:"line"` - StartLine int `json:"startLine"` - // Side is RIGHT for a thread on the new file, LEFT for one on a deleted - // line — the two sides the diff renders in one document. - Side string `json:"side"` - IsResolved bool `json:"isResolved"` - IsOutdated bool `json:"isOutdated"` - Comments []PRThreadComment `json:"comments"` -} +type PRReviewThread = api.PRReviewThread -// PRThreadComment is one message inside a thread. -type PRThreadComment struct { - // ID is the REST database id — the address a reply is posted to. GraphQL - // node ids do not work there, which is why both identifiers travel. - ID int64 `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - Date string `json:"date"` - // DiffHunk is GitHub's own snippet of the lines the thread is about. The - // diff already has them where the thread is anchored; this is what an - // outdated thread has left. - DiffHunk string `json:"diffHunk"` - // CanEdit is GitHub's own answer to whether the account reading may - // rewrite the comment, which is what the Edit button stands on. - CanEdit bool `json:"canEdit"` - CanDelete bool `json:"canDelete"` -} +type PRThreadComment = api.PRThreadComment // ghConversation mirrors the GraphQL payload; nothing below leaves this package // in this shape. diff --git a/internal/project/revertlines.go b/internal/project/revertlines.go index efeac2824..e07b3ea09 100644 --- a/internal/project/revertlines.go +++ b/internal/project/revertlines.go @@ -2,11 +2,11 @@ package project import ( "bytes" - "encoding/json" "errors" "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relpath" ) @@ -24,44 +24,9 @@ const ( sideNew = "new" ) -// RevertLine names one changed line of the diff the panel drew. Side "old" is a -// deletion, numbered in HEAD; "new" is an addition, numbered in the working -// tree. Text is the line without its +/- prefix, and is what proves the number -// still points at the same line. -type RevertLine struct { - Side string `json:"side"` - Line int `json:"line"` - Text string `json:"text"` -} - -// RevertResult is what undoing a revert needs: the patch reversed onto the -// working tree, and the one reversed onto the index ("" when the index did not -// hold the change and was left alone). -type RevertResult struct { - Patch string - IndexPatch string -} +type RevertLine = api.RevertLine -// revertWire is RevertResult as it crosses the RPC: the patches are git's raw -// bytes, which need not be UTF-8 (a Latin-1 file), so they travel as base64 -// rather than as JSON text that would turn each such byte into U+FFFD. -type revertWire struct { - Patch []byte `json:"patch"` - IndexPatch []byte `json:"indexPatch"` -} - -func (r RevertResult) MarshalJSON() ([]byte, error) { - return json.Marshal(revertWire{Patch: []byte(r.Patch), IndexPatch: []byte(r.IndexPatch)}) -} - -func (r *RevertResult) UnmarshalJSON(data []byte) error { - var wire revertWire - if err := json.Unmarshal(data, &wire); err != nil { - return err - } - r.Patch, r.IndexPatch = string(wire.Patch), string(wire.IndexPatch) - return nil -} +type RevertResult = api.RevertResult // RevertLines puts the chosen changed lines of rel back to HEAD, leaving every // other change in the file alone. The patch is rebuilt here from git's own diff diff --git a/internal/project/search.go b/internal/project/search.go index 62eb8e2dc..ef2c97a43 100644 --- a/internal/project/search.go +++ b/internal/project/search.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "unicode/utf8" + + "github.com/lichdotdev/lich/api" ) // maxSearchMatches caps one search's answer. The list is read by a person in a @@ -24,26 +26,9 @@ const ( excerptLeadBytes = 40 ) -// SearchMatch is one line of one file that contains the query. -type SearchMatch struct { - Path string `json:"path"` - // Line is 1-based, the same numbering the preview's gutter shows. - Line int `json:"line"` - // Text is the line with its indentation dropped, cut to an excerpt around - // the first occurrence when the line is long. - Text string `json:"text"` -} +type SearchMatch = api.SearchMatch -// SearchResult is one search over a checkout's files. -type SearchResult struct { - Matches []SearchMatch `json:"matches"` - // Cut reports a search that stopped at maxSearchMatches. - Cut bool `json:"cut"` - // TooLarge counts the listed files skipped for being above maxReadFileSize. - // The tree shows them, so an answer that left them out says so; a binary - // is skipped without a count, since nobody searches one for text. - TooLarge int `json:"tooLarge"` -} +type SearchResult = api.SearchResult // errTooLarge marks a listed file readSearchable skipped for its size, the one // skip SearchResult counts. diff --git a/internal/project/tree.go b/internal/project/tree.go index dfd60e7f0..58f480b88 100644 --- a/internal/project/tree.go +++ b/internal/project/tree.go @@ -13,6 +13,7 @@ import ( "golang.org/x/text/encoding/charmap" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relpath" ) @@ -51,20 +52,7 @@ func displayText(data []byte) string { return string(decoded) } -// FileListing is one listing of a directory's files: the paths, plus the two -// things the Files panel has to say out loud about how they were gathered. A -// tree that silently stops short, or silently leaves a directory out, reads as -// a folder with fewer files in it than it has. -type FileListing struct { - Files []string `json:"files"` - // Cut reports a listing that stopped at walkLimit. Only a plain-folder walk - // can set it; git's own listing is never capped. - Cut bool `json:"cut"` - // Hidden names the walkIgnore directories this folder actually had, sorted - // and deduped: the panel names them, so somebody keeping source under a - // build/ or vendor/ reads why it is missing instead of guessing. - Hidden []string `json:"hidden"` -} +type FileListing = api.FileListing // Tree lists a directory's files as root-relative, slash-separated paths, // sorted. Inside a repository it merges tracked files with diff --git a/internal/project/worktree.go b/internal/project/worktree.go index 8c347e7fc..283c1c9ae 100644 --- a/internal/project/worktree.go +++ b/internal/project/worktree.go @@ -13,28 +13,14 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/gitutil" "github.com/lichdotdev/lich/internal/relpath" ) -// Worktree is a git worktree checkout: the branch it holds and its path. -type Worktree struct { - Name string `json:"name"` - Path string `json:"path"` - // Whether CreateWorktree checked out a branch that already existed instead - // of starting one at the base it was given — the one case where the base - // the caller picked decided nothing. Always false on a listed worktree. - Reused bool `json:"reused,omitempty"` -} +type Worktree = api.Worktree -// Branches groups everything the base-branch picker offers: local and remote -// branch names plus already-existing worktrees, so a closed-but-kept worktree -// can be resumed instead of recreated. -type Branches struct { - Local []string `json:"local"` - Remote []string `json:"remote"` // "origin/main" form - Worktrees []Worktree `json:"worktrees"` -} +type Branches = api.Branches // ListBranches returns the repository's local and remote branches and its // existing worktrees. A path that is not a git repository yields an error. diff --git a/internal/providers/providers.go b/internal/providers/providers.go index 3771172b7..f73abdbe1 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -12,6 +12,8 @@ import ( "os/exec" "path/filepath" "sync" + + "github.com/lichdotdev/lich/api" ) // Provider ids. Each id is also the session kind (store column + terminal.Start) @@ -154,23 +156,7 @@ const ( SourceSetting = "setting" ) -// Detected reports a provider and whether a binary a session could spawn was -// found — on PATH, or at the path the user configured. -// Binary is the executable name a session spawns (DefaultBinary), which the -// settings screen needs even when nothing was found: a provider id is not its -// command — Antigravity's is `agy`. -// Docs is carried on every entry, installed or not: the row that has somewhere -// to send the user is exactly the one that found nothing. -// Source is SourcePath or SourceSetting, empty when nothing was found. -type Detected struct { - ID string `json:"id"` - Name string `json:"name"` - Binary string `json:"binary"` - Installed bool `json:"installed"` - Path string `json:"path"` - Source string `json:"source"` - Docs string `json:"docs"` -} +type Detected = api.DetectedProvider // Service detects installed providers. lookPath is injected so tests drive // detection without touching the machine. diff --git a/internal/providers/verify.go b/internal/providers/verify.go index 253d106f2..f87e282b2 100644 --- a/internal/providers/verify.go +++ b/internal/providers/verify.go @@ -5,6 +5,8 @@ import ( "io/fs" "path/filepath" "strings" + + "github.com/lichdotdev/lich/api" ) // A Check's Status. Each failing value names a distinct mistake, because each @@ -12,9 +14,9 @@ import ( // screen that accepted the value says nothing. const ( // CheckOK: the binary resolves and can be executed. - CheckOK = "ok" + CheckOK = api.CheckOK // CheckEmpty: nothing is configured at this layer, so the next one answers. - CheckEmpty = "empty" + CheckEmpty = api.CheckEmpty CheckNotFound = "not-found" // CheckNotExecutable covers a file without the execute bit and a directory // — both are "found, cannot be run". @@ -30,12 +32,7 @@ const ( CheckRelative = "relative" ) -// Check is what a configured binary resolves to. Path is the absolute -// executable a session would spawn, empty for every status but CheckOK. -type Check struct { - Path string `json:"path"` - Status string `json:"status"` -} +type Check = api.BinaryCheck // Verify resolves bin exactly as the spawn does — a bare name through $PATH, a // path taken as written — and reports whether it can be run. It is what turns diff --git a/internal/providers/version.go b/internal/providers/version.go index 549048b77..5cfc11201 100644 --- a/internal/providers/version.go +++ b/internal/providers/version.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/winexec" ) @@ -25,12 +26,7 @@ const versionTimeout = 10 * time.Second // "crush version v0.98.1", "2026.10.01-e373342". var versionPattern = regexp.MustCompile(`\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.]+)?`) -// Binary is one executable a provider could be started from, and the version it -// reports, empty when it printed none lich could read. -type Binary struct { - Path string `json:"path"` - Version string `json:"version"` -} +type Binary = api.ProviderBinary // Version runs bin (resolved as Verify resolves it) with `--version` and // returns the version it printed. It is the slow half of what Detect answers, diff --git a/internal/quota/quota.go b/internal/quota/quota.go index d015c4792..8cb977579 100644 --- a/internal/quota/quota.go +++ b/internal/quota/quota.go @@ -45,6 +45,7 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -117,49 +118,9 @@ const ( dataHomeVar = "XDG_DATA_HOME" ) -// Window is one metered window of a plan: how much of it is spent, how long it -// runs, and when it starts over. -// -// Label is the window's name as the provider frames it ("Session", "Weekly"), or -// the display name of the model a scoped window belongs to. Seconds is its -// length, so the window can print the metered window it is looking at; 0 when the -// provider does not report one. ResetsAt is RFC 3339, empty when unreported. -// Active is the provider's own verdict on which window is the binding one, when -// it reports one at all. LockedReason is non-empty only when the provider says -// this window cannot be spent past regardless of its percentage. Ahead is -// lich's own reading of the rate rather than the provider's — see pace.go. -type Window struct { - Label string `json:"label"` - Seconds int `json:"seconds"` - Percent int `json:"percent"` - ResetsAt string `json:"resetsAt,omitempty"` - Active bool `json:"active,omitempty"` - LockedReason string `json:"lockedReason,omitempty"` - Ahead bool `json:"ahead,omitempty"` -} +type Window = api.QuotaWindow -// Plan is one provider's quota reading. Provider is a providers.Registry id, so -// the window resolves the icon and the login command it already knows. -// -// Account names the login the windows were read against — the whole point of -// the custom-binary path being that a session can spend an account lich's own -// environment does not name. It is empty whenever the provider will not say -// which, which is a reading without a name and never an error: a gauge that -// cannot name its account is exactly what lich drew before this field existed. -// -// NoAccount carries why it is empty, for the one absence a reader can act on. -// Left empty, an unnamed account is drawn the same whether the provider names -// nobody or the session's own login is one lich may not ask about, and nothing -// on the gauge told the two apart. -type Plan struct { - Provider string `json:"provider"` - Name string `json:"name"` - Plan string `json:"plan,omitempty"` - Account string `json:"account,omitempty"` - NoAccount string `json:"noAccount,omitempty"` - Windows []Window `json:"windows,omitempty"` - Status string `json:"status"` -} +type Plan = api.QuotaPlan // NoAccountTokenLogin is the one reason an empty Account has a name: the // session's login is a long-lived OAuth token, whose scope keeps the profile diff --git a/internal/relay/answer.go b/internal/relay/answer.go index a005636f1..1413e5094 100644 --- a/internal/relay/answer.go +++ b/internal/relay/answer.go @@ -6,13 +6,11 @@ import ( "sort" "strings" "time" + + "github.com/lichdotdev/lich/api" ) -// WaitOptions is one Wait call, an object for the reason SendOptions is one. -type WaitOptions struct { - Ticket string `json:"ticket"` - WaitSeconds int `json:"waitSeconds"` -} +type WaitOptions = api.WaitOptions // Wait blocks on an already-delivered ticket for another round, so a caller // whose first wait ran out can come back without sending the message twice. @@ -68,13 +66,7 @@ func (s *Service) Wait(ctx context.Context, opts WaitOptions) (Result, error) { return result, nil } -// ReplyOptions is one Reply call, an object for the reason SendOptions is one. -// From is the session replying. -type ReplyOptions struct { - From string `json:"from"` - Ticket string `json:"ticket"` - Answer string `json:"answer"` -} +type ReplyOptions = api.ReplyOptions // Reply hands an answer back to whoever is waiting on Ticket. It is what the // message composed by Send asks the receiving agent to run. diff --git a/internal/relay/focus.go b/internal/relay/focus.go index a3d2e6732..5f375488a 100644 --- a/internal/relay/focus.go +++ b/internal/relay/focus.go @@ -1,22 +1,13 @@ package relay -// FocusEventName asks the window to open a session's card, the way a click on -// it does. Global like the other session events: the card it names may live in -// a project whose tab is not the one on screen. -const FocusEventName = "session-focus" +import ( + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) -// FocusEvent is the payload of FocusEventName. -type FocusEvent struct { - ID string `json:"id"` -} +type FocusEvent = api.SessionFocus -// Focused says which session's card was brought up, so a caller that named it -// loosely can tell the person which one it was. -type Focused struct { - ID string `json:"id"` - Label string `json:"label"` - Project string `json:"project"` -} +type Focused = api.FocusedSession // SetRaiseWindow wires how the relay brings lich's window to the front, or // opens one on the session's card when none is open. Nil (a test that does not @@ -25,12 +16,7 @@ func (s *Service) SetRaiseWindow(raise func(sessionID string)) { s.raiseWindow = raise } -// FocusOptions is one Focus call, an object for the reason SendOptions is one. -type FocusOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` -} +type FocusOptions = api.FocusSessionOptions // Focus opens the card of the session Target names and brings the window to // the front, for an editor that just pointed the person at that session. @@ -42,7 +28,7 @@ func (s *Service) Focus(opts FocusOptions) (Focused, error) { return Focused{}, err } if s.events != nil { - s.events.Emit(FocusEventName, FocusEvent{ID: dest.ID}) + events.Send(s.events.Emit, api.SessionFocusEvent, FocusEvent{ID: dest.ID}) } if s.raiseWindow != nil { s.raiseWindow(dest.ID) diff --git a/internal/relay/inbox.go b/internal/relay/inbox.go index 716c8e6ba..6e196a859 100644 --- a/internal/relay/inbox.go +++ b/internal/relay/inbox.go @@ -7,6 +7,9 @@ import ( "sort" "sync" "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" ) // The inbox: where an errand's outcome waits when nobody was holding the line @@ -17,24 +20,9 @@ import ( // of itself would otherwise cost one nudge, and one restarted turn, per worker. const defaultNudgeDelay = 2 * time.Second -// InboxEventName carries how many uncollected results a session has waiting, -// so its card can say so — the nudge tells the agent, and this tells the -// person. Emitted whenever a sender's inbox changes size; zero clears the -// mark. Global for the same reason the relay event is. -const InboxEventName = "session-inbox" - -// InboxEvent is the payload of InboxEventName. -type InboxEvent struct { - ID string `json:"id"` - Count int `json:"count"` -} +type InboxEvent = api.SessionInbox -// Collected is what a drain returns: every outcome that was waiting for the -// caller, oldest first, and the labels of the sessions still owing one. -type Collected struct { - Results []Result `json:"results"` - Open []string `json:"open"` -} +type Collected = api.CollectedResults // inboxEntry is one finished errand whose outcome nobody was holding the line // for. The result is kept here until the sender collects it instead of being @@ -62,12 +50,7 @@ type inboxEntry struct { seen bool } -// CollectOptions is one Collect call, an object for the reason SendOptions is -// one. -type CollectOptions struct { - From string `json:"from"` - WaitSeconds int `json:"waitSeconds"` -} +type CollectOptions = api.CollectOptions // Collect drains every outcome waiting for From, oldest first. With nothing // ready and errands still open it holds the line for the next one; with @@ -82,11 +65,7 @@ func (s *Service) Collect(ctx context.Context, opts CollectOptions) (Collected, return s.collect(ctx, opts.From, waitFor(opts.WaitSeconds)) } -// CollectNowOptions is one CollectNow call, an object for the reason -// SendOptions is one. -type CollectNowOptions struct { - From string `json:"from"` -} +type CollectNowOptions = api.CollectNowOptions // CollectNow is Collect without the wait: what is ready and who still owes one, // at once. It is how a sender deep in a turn of its own checks in at a decision @@ -292,7 +271,7 @@ func (s *Service) announceInbox(fromID string) { s.mu.Lock() count := s.countLocked(fromID) s.mu.Unlock() - s.events.Emit(InboxEventName, InboxEvent{ID: fromID, Count: count}) + events.Send(s.events.Emit, api.SessionInboxEvent, InboxEvent{ID: fromID, Count: count}) } func (s *Service) announceInboxAll(senders []string) { diff --git a/internal/relay/insert.go b/internal/relay/insert.go index daff8dbcf..044f585c1 100644 --- a/internal/relay/insert.go +++ b/internal/relay/insert.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/lichdotdev/lich/api" ) // Insert is the other half of what an editor needs from lich: put text at a @@ -29,26 +31,9 @@ const ( defaultInsertWait = 10 * time.Second ) -// Inserted says where the text went, so an editor can tell the person which -// session took it when the target was chosen for them. -type Inserted struct { - ID string `json:"id"` - Label string `json:"label"` - Project string `json:"project"` - Kind string `json:"kind"` - // Bytes is what was pasted, after control characters were stripped (sanitize). - Bytes int `json:"bytes"` -} +type Inserted = api.InsertedText -// InsertOptions is one Insert call, an object for the reason SendOptions is -// one. -type InsertOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` - Text string `json:"text"` - WaitSeconds int `json:"waitSeconds"` -} +type InsertOptions = api.InsertTextOptions // Insert pastes Text at the prompt of the session Target names, without // sending it. Target resolves the way Send's does. Left empty, Project must diff --git a/internal/relay/later.go b/internal/relay/later.go index 2de102f7e..423b4ac3b 100644 --- a/internal/relay/later.go +++ b/internal/relay/later.go @@ -5,7 +5,10 @@ import ( "log/slog" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" + + "github.com/lichdotdev/lich/internal/events" ) // A scheduled prompt is the user's own words parked on a session to be typed at @@ -26,19 +29,7 @@ import ( // that can be asked for, and it costs one read of the open sessions. const scheduleTick = 30 * time.Second -// ScheduleEventName is emitted when a session's scheduled prompt changes -// without the window having written it: delivered, or parked and dropped by -// lich itself around a usage limit (resume.go). Payload: ScheduleEvent. -const ScheduleEventName = "session-schedule" - -// ScheduleEvent is the payload of ScheduleEventName: the session whose mark -// changed, when its prompt is now due, and the prompt. At is 0 and Prompt empty -// when nothing is waiting anymore. -type ScheduleEvent struct { - ID string `json:"id"` - At int64 `json:"at"` - Prompt string `json:"prompt,omitempty"` -} +type ScheduleEvent = api.SessionSchedule // RunSchedules types due prompts at their sessions until the process ends, // and ages out errands on the same tick (expireTickets). Started once at @@ -99,7 +90,7 @@ func (s *Service) deliverDue() { continue } if s.events != nil { - s.events.Emit(ScheduleEventName, ScheduleEvent{ID: sess.ID}) + events.Send(s.events.Emit, api.SessionScheduleEvent, ScheduleEvent{ID: sess.ID}) } text := lateNotice(s.lang(), sess.ScheduledAt, now) + sess.ScheduledPrompt if err := s.deliver(sess.ID, text, nil); err != nil { diff --git a/internal/relay/observe.go b/internal/relay/observe.go index 0fb99090d..f050a3341 100644 --- a/internal/relay/observe.go +++ b/internal/relay/observe.go @@ -3,6 +3,9 @@ package relay import ( "log/slog" "sort" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" ) // Observe takes one session-state report from the hooks the provider already @@ -50,7 +53,7 @@ func (s *Service) Observe(sessionID, state string) { for _, e := range ended { s.clear(e.t) if s.events != nil { - s.events.Emit(StalledEventName, StalledEvent{ + events.Send(s.events.Emit, api.RelayStalledEvent, StalledEvent{ ID: e.t.fromID, TargetID: e.t.targetID, Target: e.t.target, }) } @@ -292,7 +295,7 @@ func (s *Service) announce(sessionID, peer, direction, ticketID string) { if s.events == nil || sessionID == "" { return } - s.events.Emit(RelayEventName, RelayEvent{ + events.Send(s.events.Emit, api.SessionRelayEvent, RelayEvent{ ID: sessionID, Peer: peer, Direction: direction, Ticket: ticketID, }) } diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 3f914b720..f621203c0 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -25,6 +25,7 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/store" ) @@ -142,11 +143,6 @@ const ( stateInterrupted = "interrupted" ) -// RelayEventName carries which session is waiting on which, so the sidebar can -// say it. Global rather than per-session because its consumer — one store keyed -// by id — outlives any one card, exactly as the status event's does. -const RelayEventName = "session-relay" - // Directions a RelayEvent carries. An empty direction clears the mark: the // request it named is over, answered or expired. const ( @@ -154,37 +150,9 @@ const ( DirectionIn = "in" ) -// StalledEventName carries a request whose target ended its turn without -// answering through lich. The window turns it into a toast that opens the -// target's card, because the answer — if there is one — is on that screen and -// the person who asked has no other way to know where it went. -const StalledEventName = "session-relay-stalled" - -// StalledEvent is the payload of StalledEventName: who asked ("" when the -// request came from the command line rather than a session), and the session -// that has whatever was produced. -type StalledEvent struct { - ID string `json:"id"` - TargetID string `json:"targetId"` - Target string `json:"target"` -} +type StalledEvent = api.RelayStalled -// RelayEvent is the payload of RelayEventName: the session whose mark changed, -// the label at the other end, which way the request runs, and the ticket the -// two ends share. Peer is empty when the other end is not a session at all — -// the CLI run from a script or a shell — which the card words its own way. -// -// The ticket rides along because it is otherwise written down in exactly one -// place: the message typed at the target's prompt. An agent whose context was -// compacted past that message has no way back to it, and neither had the person -// watching — the window knew a request was open and could not say which one. -// Empty when the mark is being cleared, along with the direction. -type RelayEvent struct { - ID string `json:"id"` - Peer string `json:"peer"` - Direction string `json:"direction"` - Ticket string `json:"ticket"` -} +type RelayEvent = api.SessionRelay // Sessions is the persistence the relay reads: every open session, so a label // can be resolved to the session it names and the caller can be told what it @@ -241,50 +209,9 @@ type Terminal interface { ModAnswers(id string) bool } -// Peer is one session a caller may address: the label it is addressed by, the -// name it answers to in Claude Code's peer roster, the project it belongs to -// (labels are unique within a project, not across them), what is running in it, -// and what that session last reported it was doing. -// -// Both names are published because both reach this session and an agent sees -// them in different places — the label on the card, the roster name in -// `/list-agents` and in what a mention writes at a prompt. Showing one and -// accepting the other is what made an agent treat a single session as two. -// -// State is stateBusy, stateWaiting or stateDone, and empty when the session has -// reported nothing — which is a provider whose plugin does not report state as -// much as a session that has not had a turn yet. Empty is "not known", never -// "idle": guessing the second is how a caller ends up sending work into a -// session that cannot take it. -type Peer struct { - Label string `json:"label"` - Name string `json:"name"` - Project string `json:"project"` - Kind string `json:"kind"` - State string `json:"state"` - // ID is the session's lich id, the LICH_SESSION_ID its process carries, - // so a reader holding a recorded id can find its card. Last, because - // fields are appended to this shape, never inserted. - ID string `json:"id"` - // Path is the directory the session runs in, the worktree's for a worktree - // session, and ProjectPath the root of the project it belongs to. They are - // what an editor matches its open folder against to find the session beside - // it; the name of a project says nothing about where it is. - Path string `json:"path"` - ProjectPath string `json:"projectPath"` -} +type Peer = api.Peer -// Result is what a caller gets back from Send or Wait. Answer is empty unless -// Status is StatusAnswered. -type Result struct { - Ticket string `json:"ticket"` - Target string `json:"target"` - Status string `json:"status"` - Answer string `json:"answer"` - // Private is set on the outcome of a ticket sent with SendPrivate, which - // only that ticket collects: no note will announce it. - Private bool `json:"private,omitempty"` -} +type Result = api.SendResult // ticket is one outstanding errand. answer is written once, before done is // closed, so every waiter reads it safely after the close. @@ -560,10 +487,7 @@ func (s *Service) lang() prompt.Lang { return s.promptLang() } -// PeersOptions is one Peers call, an object for the reason SendOptions is one. -type PeersOptions struct { - From string `json:"from"` -} +type PeersOptions = api.PeersOptions // Peers lists the live sessions From may address, in the order the sidebar // shows them. A session with no PTY running is left out: there is nothing there @@ -581,18 +505,7 @@ func (s *Service) Peers(opts PeersOptions) ([]Peer, error) { return peers, nil } -// SendOptions is one Send, SendPrivate or SendSubagent call. It travels as a -// single JSON object rather than as positional arguments because a package -// manager replaces the lich binary under a running backend: a field a newer -// lich adds is dropped by an older backend, and one an older lich does not send -// is its zero value, where a positional list refuses both on its count. -type SendOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` - Prompt string `json:"prompt"` - WaitSeconds int `json:"waitSeconds"` -} +type SendOptions = api.SendOptions // Send types Prompt at the prompt of the session labelled Target and waits for // its agent to answer. Project narrows the search when the same label exists in diff --git a/internal/relay/relay_test.go b/internal/relay/relay_test.go index 941c3ce3d..a38ede5f3 100644 --- a/internal/relay/relay_test.go +++ b/internal/relay/relay_test.go @@ -10,6 +10,7 @@ import ( "time" "unicode/utf8" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/store" ) @@ -265,23 +266,23 @@ func (f *fakeEvents) Emit(name string, data any) { defer f.mu.Unlock() switch event := data.(type) { case RelayEvent: - if name == RelayEventName { + if name == api.SessionRelayEvent.Name { f.sent = append(f.sent, event) } case StalledEvent: - if name == StalledEventName { + if name == api.RelayStalledEvent.Name { f.halts = append(f.halts, event) } case ScheduleEvent: - if name == ScheduleEventName { + if name == api.SessionScheduleEvent.Name { f.marks = append(f.marks, event) } case InboxEvent: - if name == InboxEventName { + if name == api.SessionInboxEvent.Name { f.inbox = append(f.inbox, event) } case FocusEvent: - if name == FocusEventName { + if name == api.SessionFocusEvent.Name { f.focus = append(f.focus, event) } } diff --git a/internal/relay/resume.go b/internal/relay/resume.go index 4ede412fa..28465759e 100644 --- a/internal/relay/resume.go +++ b/internal/relay/resume.go @@ -5,8 +5,11 @@ import ( "slices" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/prompt" "github.com/lichdotdev/lich/internal/store" + + "github.com/lichdotdev/lich/internal/events" ) // A turn a plan's usage limit ended is picked up again once the limit resets: @@ -61,7 +64,7 @@ func (s *Service) ParkResume(id string, resetsAt int64) { } s.markResume(id) if s.events != nil { - s.events.Emit(ScheduleEventName, ScheduleEvent{ID: id, At: due, Prompt: resumePrompt}) + events.Send(s.events.Emit, api.SessionScheduleEvent, ScheduleEvent{ID: id, At: due, Prompt: resumePrompt}) } } @@ -79,7 +82,7 @@ func (s *Service) dropResume(id string) { return } if s.events != nil { - s.events.Emit(ScheduleEventName, ScheduleEvent{ID: id}) + events.Send(s.events.Emit, api.SessionScheduleEvent, ScheduleEvent{ID: id}) } } diff --git a/internal/relay/roster.go b/internal/relay/roster.go index dea9db893..fda496301 100644 --- a/internal/relay/roster.go +++ b/internal/relay/roster.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) @@ -39,10 +40,7 @@ func (s *Service) roster(fromID string) ([]candidate, error) { return found, nil } -// SelfOptions is one Self call, an object for the reason SendOptions is one. -type SelfOptions struct { - From string `json:"from"` -} +type SelfOptions = api.SelfOptions // Self is the caller's own entry, in the shape Peers lists everybody else's: the // one session the roster never shows it. It does not ask whether a process is diff --git a/internal/relay/workeranswer.go b/internal/relay/workeranswer.go index 009872074..44ac07437 100644 --- a/internal/relay/workeranswer.go +++ b/internal/relay/workeranswer.go @@ -1,6 +1,11 @@ package relay -import "log/slog" +import ( + "log/slog" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) // A subagent worker whose mod answers for it (docs/hooks/mod-answer.md) and a // worker its caller closed: the two ways a subagent errand ends that a native @@ -103,7 +108,7 @@ func (s *Service) WorkerUnanswered(workerID, reason string) { s.clear(t) if s.events != nil { - s.events.Emit(StalledEventName, StalledEvent{ID: t.fromID, TargetID: t.targetID, Target: t.target}) + events.Send(s.events.Emit, api.RelayStalledEvent, StalledEvent{ID: t.fromID, TargetID: t.targetID, Target: t.target}) } if unattended { s.announceInbox(t.fromID) diff --git a/internal/routes/app.go b/internal/routes/app.go new file mode 100644 index 000000000..257781208 --- /dev/null +++ b/internal/routes/app.go @@ -0,0 +1,56 @@ +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/agentplugin" + "github.com/lichdotdev/lich/internal/appupdate" + "github.com/lichdotdev/lich/internal/drop" + "github.com/lichdotdev/lich/internal/patchnotes" + "github.com/lichdotdev/lich/internal/providers" + "github.com/lichdotdev/lich/internal/rpc" +) + +// Drop exposes the drop service as api declares it. A dropped file's bytes +// arrive on /drop, outside the RPC's JSON envelope. +func Drop(d *rpc.Handler, s *drop.Service) { + rpc.Handle(d, api.DropAttach, func(_ context.Context, o api.AttachDropOptions) (api.DropAttachment, error) { + return s.Attach(o.SessionID, o.Root, o.Confined) + }) +} + +// PatchNotes exposes the patch notes as api declares them. +func PatchNotes(d *rpc.Handler, s *patchnotes.Service) { + rpc.Handle(d, api.PatchNotesCurrent, func(_ context.Context, o api.PatchNotesOptions) (api.PatchNotes, error) { + return s.Current(o.Locale) + }) +} + +// AppUpdate exposes the app update service as api declares it. +func AppUpdate(d *rpc.Handler, s *appupdate.Service) { + rpc.Handle(d, api.AppUpdateGetStatus, func(context.Context, api.None) (api.AppUpdateStatus, error) { return s.Status(), nil }) + rpc.Handle(d, api.AppUpdateApply, func(context.Context, api.None) (api.None, error) { return api.None{}, s.Apply() }) +} + +// AgentPlugin exposes the lich plugin's install state as api declares it. +func AgentPlugin(d *rpc.Handler, s *agentplugin.Service) { + rpc.Handle(d, api.AgentPluginStatus, func(context.Context, api.None) ([]api.PluginStatus, error) { return s.Status(), nil }) + rpc.Handle(d, api.AgentPluginInstall, func(_ context.Context, o api.PluginProviderOptions) (api.None, error) { + return api.None{}, s.Install(o.Provider) + }) + rpc.Handle(d, api.AgentPluginUpdate, func(_ context.Context, o api.PluginProviderOptions) (api.None, error) { + return api.None{}, s.Update(o.Provider) + }) +} + +// Providers exposes provider detection as api declares it. +func Providers(d *rpc.Handler, s *providers.Service) { + rpc.Handle(d, api.ProvidersDetect, func(context.Context, api.None) ([]api.DetectedProvider, error) { return s.Detect() }) + rpc.Handle(d, api.ProvidersOnPath, func(ctx context.Context, o api.ProviderIDOptions) ([]api.ProviderBinary, error) { + return s.OnPath(ctx, o.ID) + }) + rpc.Handle(d, api.ProvidersRefreshPath, func(context.Context, api.None) (api.None, error) { return api.None{}, s.RefreshPath() }) + rpc.Handle(d, api.ProvidersVerify, func(_ context.Context, o api.BinaryOptions) (api.BinaryCheck, error) { return s.Verify(o.Bin), nil }) + rpc.Handle(d, api.ProvidersVersion, func(ctx context.Context, o api.BinaryOptions) (string, error) { return s.Version(ctx, o.Bin) }) +} diff --git a/internal/routes/profiles.go b/internal/routes/profiles.go new file mode 100644 index 000000000..7968ad1ab --- /dev/null +++ b/internal/routes/profiles.go @@ -0,0 +1,89 @@ +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/quota" + "github.com/lichdotdev/lich/internal/rpc" +) + +// ProfileService is what every provider's profile service answers +// (claudeprofile, codexprofile, ompprofile, xdgprofile). +type ProfileService interface { + Profiles() ([]api.ProfileInfo, error) + CreateProfile(name string) (api.Profile, error) + UpdateProfile(slug string, u api.ProfileUpdate) (api.Profile, error) + DeleteProfile(slug string) error + LoginCommand(slug string) (string, error) +} + +// ProfileLogins is a profile service that runs its provider's login itself. +type ProfileLogins interface { + Login(slug string) error + CancelLogin(slug string) +} + +// ProfilePlugins is a profile service that carries plugins between profiles. +type ProfilePlugins interface { + Plugins(slug string) ([]api.ProfilePlugin, error) + InstallPlugins(slug string, keys []string) ([]api.ProfilePluginResult, error) +} + +// Profiles exposes s as the profile methods m. +func Profiles(d *rpc.Handler, m api.ProfileMethods, s ProfileService) { + rpc.Handle(d, m.Profiles, func(context.Context, api.None) ([]api.ProfileInfo, error) { return s.Profiles() }) + rpc.Handle(d, m.CreateProfile, func(_ context.Context, o api.CreateProfileOptions) (api.Profile, error) { + return s.CreateProfile(o.Name) + }) + rpc.Handle(d, m.UpdateProfile, func(_ context.Context, o api.UpdateProfileOptions) (api.Profile, error) { + return s.UpdateProfile(o.Slug, o.Update) + }) + rpc.Handle(d, m.DeleteProfile, func(_ context.Context, o api.ProfileSlugOptions) (api.None, error) { + return api.None{}, s.DeleteProfile(o.Slug) + }) + rpc.Handle(d, m.LoginCommand, func(_ context.Context, o api.ProfileSlugOptions) (string, error) { + return s.LoginCommand(o.Slug) + }) +} + +// ProfileLogin exposes s's login as the methods m. +func ProfileLogin(d *rpc.Handler, m api.ProfileLoginMethods, s ProfileLogins) { + rpc.Handle(d, m.Login, func(_ context.Context, o api.ProfileSlugOptions) (api.None, error) { + return api.None{}, s.Login(o.Slug) + }) + rpc.Handle(d, m.CancelLogin, func(_ context.Context, o api.ProfileSlugOptions) (api.None, error) { + s.CancelLogin(o.Slug) + return api.None{}, nil + }) +} + +// ProfilePluginCalls exposes s's plugin calls as the methods m. +func ProfilePluginCalls(d *rpc.Handler, m api.ProfilePluginMethods, s ProfilePlugins) { + rpc.Handle(d, m.Plugins, func(_ context.Context, o api.ProfileSlugOptions) ([]api.ProfilePlugin, error) { + return s.Plugins(o.Slug) + }) + rpc.Handle(d, m.InstallPlugins, func(_ context.Context, o api.InstallPluginsOptions) ([]api.ProfilePluginResult, error) { + return s.InstallPlugins(o.Slug, o.Keys) + }) +} + +// ClaudeLoginCode hands a running Claude Code login the code its page showed. +func ClaudeLoginCode(d *rpc.Handler, loginCode func(slug, code string) error) { + rpc.Handle(d, api.ClaudeProfileLoginCode, func(_ context.Context, o api.LoginCodeOptions) (api.None, error) { + return api.None{}, loginCode(o.Slug, o.Code) + }) +} + +// Quota exposes the plan readings as api declares them. +func Quota(d *rpc.Handler, s *quota.Service) { + rpc.Handle(d, api.QuotaPlans, func(_ context.Context, o api.QuotaPlansOptions) ([]api.QuotaPlan, error) { + return s.Plans(o.SessionID), nil + }) + rpc.Handle(d, api.QuotaProfilePlan, func(_ context.Context, o api.ProfilePlanOptions) (api.QuotaPlan, error) { + return s.ProfilePlan(o.Profile) + }) + rpc.Handle(d, api.QuotaCodexProfilePlan, func(_ context.Context, o api.ProfilePlanOptions) (api.QuotaPlan, error) { + return s.CodexProfilePlan(o.Profile) + }) +} diff --git a/internal/routes/project.go b/internal/routes/project.go new file mode 100644 index 000000000..bf968eab5 --- /dev/null +++ b/internal/routes/project.go @@ -0,0 +1,189 @@ +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/project" + "github.com/lichdotdev/lich/internal/rpc" +) + +// Project exposes the project service as api declares it. +func Project(d *rpc.Handler, s *project.Service) { + projectFiles(d, s) + projectChanges(d, s) + projectBranches(d, s) + projectPullRequests(d, s) + projectPullRequestWrites(d, s) +} + +func projectFiles(d *rpc.Handler, s *project.Service) { + rpc.Handle(d, api.ProjectOpen, func(context.Context, api.None) (*api.OpenedProject, error) { + return s.Open() + }) + rpc.Handle(d, api.ProjectHome, func(context.Context, api.None) (*api.OpenedProject, error) { + return s.Home() + }) + rpc.Handle(d, api.ProjectRelocate, func(_ context.Context, o api.ProjectIDOptions) (*api.OpenedProject, error) { + return s.Relocate(o.ID) + }) + rpc.Handle(d, api.ProjectExists, func(_ context.Context, o api.PathOptions) (bool, error) { + return s.Exists(o.Path), nil + }) + rpc.Handle(d, api.ProjectMissing, func(_ context.Context, o api.PathsOptions) ([]string, error) { + return s.Missing(o.Paths), nil + }) + rpc.Handle(d, api.ProjectPickFile, func(_ context.Context, o api.PickFileOptions) (string, error) { + return s.PickFile(o.Title) + }) + rpc.Handle(d, api.ProjectPickSaveFile, func(_ context.Context, o api.PickSaveFileOptions) (string, error) { + return s.PickSaveFile(o.Title, o.DefaultName) + }) + rpc.Handle(d, api.ProjectTree, func(_ context.Context, o api.PathOptions) (api.FileListing, error) { + return s.Tree(o.Path) + }) + rpc.Handle(d, api.ProjectSearch, func(_ context.Context, o api.SearchOptions) (api.SearchResult, error) { + return s.Search(o.Path, o.Query) + }) + rpc.Handle(d, api.ProjectReadFile, func(_ context.Context, o api.CheckoutFileOptions) (string, error) { + return s.ReadFile(o.Path, o.Rel) + }) + rpc.Handle(d, api.ProjectReadFileAt, func(_ context.Context, o api.ReadFileAtOptions) (string, error) { + return s.ReadFileAt(o.Path, o.Rel, o.Ref) + }) + rpc.Handle(d, api.ProjectFileLines, func(_ context.Context, o api.FileLinesOptions) ([]string, error) { + return s.FileLines(o.Path, o.Rel, o.Ref, o.From, o.To) + }) +} + +func projectChanges(d *rpc.Handler, s *project.Service) { + rpc.Handle(d, api.ProjectDiff, func(_ context.Context, o api.PathOptions) (api.DiffStats, error) { + return s.Diff(o.Path), nil + }) + rpc.Handle(d, api.ProjectDiffText, func(_ context.Context, o api.PathOptions) (string, error) { + return s.DiffText(o.Path) + }) + rpc.Handle(d, api.ProjectDiscardFile, func(_ context.Context, o api.CheckoutFileOptions) (api.None, error) { + return api.None{}, s.DiscardFile(o.Path, o.Rel) + }) + rpc.Handle(d, api.ProjectRevertLines, func(_ context.Context, o api.RevertLinesOptions) (api.RevertResult, error) { + return s.RevertLines(o.Path, o.Rel, o.Lines) + }) + rpc.Handle(d, api.ProjectRestoreLines, func(_ context.Context, o api.RestoreLinesOptions) (api.None, error) { + return api.None{}, s.RestoreLines(o.Path, o.Rel, o.Undo) + }) + rpc.Handle(d, api.ProjectCarryUncommitted, func(_ context.Context, o api.CarryUncommittedOptions) (api.None, error) { + return api.None{}, s.CarryUncommitted(o.Src, o.Dst) + }) + rpc.Handle(d, api.ProjectCommitIdentity, func(_ context.Context, o api.PathOptions) (api.CommitIdentity, error) { + return s.CommitIdentity(o.Path), nil + }) +} + +func projectBranches(d *rpc.Handler, s *project.Service) { + rpc.Handle(d, api.ProjectBranchesOf, func(_ context.Context, o api.PathsOptions) (map[string]string, error) { + return s.BranchesOf(o.Paths), nil + }) + rpc.Handle(d, api.ProjectBaseStatus, func(_ context.Context, o api.PathOptions) (*api.BaseStatus, error) { + return s.BaseStatus(o.Path), nil + }) + rpc.Handle(d, api.ProjectBaseFreshness, func(_ context.Context, o api.PathBranchOptions) (api.BaseFreshness, error) { + return s.BaseFreshness(o.Path, o.Branch) + }) + rpc.Handle(d, api.ProjectBranchRules, func(_ context.Context, o api.PathBranchOptions) (*api.BranchRules, error) { + return s.BranchRules(o.Path, o.Branch) + }) + rpc.Handle(d, api.ProjectListBranches, func(_ context.Context, o api.PathOptions) (api.Branches, error) { + return s.ListBranches(o.Path) + }) + rpc.Handle(d, api.ProjectListCheckouts, func(_ context.Context, o api.PathOptions) ([]api.Worktree, error) { + return s.ListCheckouts(o.Path) + }) + rpc.Handle(d, api.ProjectCreateWorktree, func(_ context.Context, o api.CreateWorktreeOptions) (*api.Worktree, error) { + return s.CreateWorktree(o.ProjectPath, o.ProjectID, o.Name, o.Base, o.BaseIsRemote) + }) + rpc.Handle(d, api.ProjectCreateWorktreeFromPR, func(_ context.Context, o api.CreateWorktreeFromPROptions) (*api.Worktree, error) { + return s.CreateWorktreeFromPR(o.ProjectPath, o.ProjectID, o.Number) + }) + rpc.Handle(d, api.ProjectRemoveWorktree, func(_ context.Context, o api.RemoveWorktreeOptions) (api.None, error) { + return api.None{}, s.RemoveWorktree(o.ProjectPath, o.WorktreePath, o.Force, o.AdoptedAck) + }) + rpc.Handle(d, api.ProjectWorktreeDirty, func(_ context.Context, o api.PathOptions) (bool, error) { + return s.WorktreeDirty(o.Path) + }) + rpc.Handle(d, api.ProjectWorktreeAdopted, func(_ context.Context, o api.PathOptions) (bool, error) { + return s.WorktreeAdopted(o.Path), nil + }) +} + +func projectPullRequests(d *rpc.Handler, s *project.Service) { + rpc.Handle(d, api.ProjectGitHubAccounts, func(context.Context, api.None) ([]string, error) { + return s.GitHubAccounts() + }) + rpc.Handle(d, api.ProjectIssue, func(_ context.Context, o api.NumberOptions) (api.Issue, error) { + return s.Issue(o.Path, o.Number) + }) + rpc.Handle(d, api.ProjectPullRequest, func(_ context.Context, o api.PathOptions) (*api.PullRequest, error) { + return s.PullRequest(o.Path), nil + }) + rpc.Handle(d, api.ProjectListPullRequests, func(_ context.Context, o api.ListPullRequestsOptions) ([]api.PRSummary, error) { + return s.ListPullRequests(o.Path, o.State) + }) + rpc.Handle(d, api.ProjectPullRequestDetail, func(_ context.Context, o api.NumberOptions) (*api.PRDetail, error) { + return s.PullRequestDetail(o.Path, o.Number) + }) + rpc.Handle(d, api.ProjectPullRequestConversation, func(_ context.Context, o api.NumberOptions) (*api.PRConversation, error) { + return s.PullRequestConversation(o.Path, o.Number) + }) + rpc.Handle(d, api.ProjectPullRequestDiff, func(_ context.Context, o api.NumberOptions) (string, error) { + return s.PullRequestDiff(o.Path, o.Number) + }) + rpc.Handle(d, api.ProjectPullRequestMergeBase, func(_ context.Context, o api.NumberOptions) (string, error) { + return s.PullRequestMergeBase(o.Path, o.Number) + }) + rpc.Handle(d, api.ProjectPullRequestConflicts, func(_ context.Context, o api.PRConflictsOptions) ([]string, error) { + return s.PullRequestConflicts(o.Path, o.Number, o.Base, o.PRURL) + }) + rpc.Handle(d, api.ProjectCreatePullRequest, func(_ context.Context, o api.PathOptions) (api.None, error) { + return api.None{}, s.CreatePullRequest(o.Path) + }) + rpc.Handle(d, api.ProjectMergePullRequest, func(_ context.Context, o api.MergePullRequestOptions) (api.None, error) { + return api.None{}, s.MergePullRequest(o.Path, o.Number, o.Method, o.Subject, o.Body, o.Admin) + }) +} + +func projectPullRequestWrites(d *rpc.Handler, s *project.Service) { + rpc.Handle(d, api.ProjectEditPullRequestTitle, func(_ context.Context, o api.PRTitleOptions) (api.None, error) { + return api.None{}, s.EditPullRequestTitle(o.Path, o.Number, o.Title) + }) + rpc.Handle(d, api.ProjectEditPullRequestBody, func(_ context.Context, o api.PRBodyOptions) (api.None, error) { + return api.None{}, s.EditPullRequestBody(o.Path, o.Number, o.Body) + }) + rpc.Handle(d, api.ProjectCommentOnPullRequest, func(_ context.Context, o api.PRBodyOptions) (api.None, error) { + return api.None{}, s.CommentOnPullRequest(o.Path, o.Number, o.Body) + }) + rpc.Handle(d, api.ProjectEditPullRequestComment, func(_ context.Context, o api.PRCommentBodyOptions) (api.None, error) { + return api.None{}, s.EditPullRequestComment(o.Path, o.Number, o.CommentID, o.Body) + }) + rpc.Handle(d, api.ProjectDeletePullRequestComment, func(_ context.Context, o api.PRCommentOptions) (api.None, error) { + return api.None{}, s.DeletePullRequestComment(o.Path, o.Number, o.CommentID) + }) + rpc.Handle(d, api.ProjectSubmitReview, func(_ context.Context, o api.SubmitReviewOptions) (api.None, error) { + return api.None{}, s.SubmitReview(o.Path, o.Number, o.Event, o.Body, o.Head, o.Comments) + }) + rpc.Handle(d, api.ProjectEditReviewBody, func(_ context.Context, o api.PRReviewBodyOptions) (api.None, error) { + return api.None{}, s.EditReviewBody(o.Path, o.Number, o.ReviewID, o.Body) + }) + rpc.Handle(d, api.ProjectEditReviewComment, func(_ context.Context, o api.PRCommentBodyOptions) (api.None, error) { + return api.None{}, s.EditReviewComment(o.Path, o.Number, o.CommentID, o.Body) + }) + rpc.Handle(d, api.ProjectDeleteReviewComment, func(_ context.Context, o api.PRCommentOptions) (api.None, error) { + return api.None{}, s.DeleteReviewComment(o.Path, o.Number, o.CommentID) + }) + rpc.Handle(d, api.ProjectReplyToReviewThread, func(_ context.Context, o api.PRCommentBodyOptions) (api.None, error) { + return api.None{}, s.ReplyToReviewThread(o.Path, o.Number, o.CommentID, o.Body) + }) + rpc.Handle(d, api.ProjectResolveReviewThread, func(_ context.Context, o api.ResolveThreadOptions) (api.None, error) { + return api.None{}, s.ResolveReviewThread(o.Path, o.ThreadID, o.Resolved) + }) +} diff --git a/internal/routes/relay.go b/internal/routes/relay.go new file mode 100644 index 000000000..605326c5e --- /dev/null +++ b/internal/routes/relay.go @@ -0,0 +1,34 @@ +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/relay" + "github.com/lichdotdev/lich/internal/rpc" +) + +// Relay exposes the relay service as api declares it. The hooks' state +// stream and a worker's answer stay out: they arrive over /hook and +// /mod/answer, and called here would act for another session. +func Relay(d *rpc.Handler, s *relay.Service) { + rpc.Handle(d, api.RelayPeers, func(_ context.Context, o api.PeersOptions) ([]api.Peer, error) { return s.Peers(o) }) + rpc.Handle(d, api.RelaySelf, func(_ context.Context, o api.SelfOptions) (api.Peer, error) { return s.Self(o) }) + rpc.Handle(d, api.RelaySend, s.Send) + rpc.Handle(d, api.RelaySendPrivate, s.SendPrivate) + rpc.Handle(d, api.RelaySendSubagent, s.SendSubagent) + rpc.Handle(d, api.RelayWait, s.Wait) + rpc.Handle(d, api.RelayCollect, s.Collect) + rpc.Handle(d, api.RelayCollectNow, func(_ context.Context, o api.CollectNowOptions) (api.CollectedResults, error) { + return s.CollectNow(o) + }) + rpc.Handle(d, api.RelayReply, func(_ context.Context, o api.ReplyOptions) (api.None, error) { return api.None{}, s.Reply(o) }) + rpc.Handle(d, api.RelayInsert, func(_ context.Context, o api.InsertTextOptions) (api.InsertedText, error) { return s.Insert(o) }) + rpc.Handle(d, api.RelayFocus, func(_ context.Context, o api.FocusSessionOptions) (api.FocusedSession, error) { return s.Focus(o) }) + rpc.Handle(d, api.RelayAnnounceMerge, func(_ context.Context, o api.AnnounceMergeOptions) (api.None, error) { + return api.None{}, s.AnnounceMerge(o.Checkout, o.Number, o.Title, o.Branch, o.Base) + }) + rpc.Handle(d, api.RelaySubmit, func(_ context.Context, o api.SubmitOptions) (api.None, error) { + return api.None{}, s.Submit(o.SessionID, o.Message) + }) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go new file mode 100644 index 000000000..5dfcaa29c --- /dev/null +++ b/internal/routes/routes.go @@ -0,0 +1,76 @@ +// Package routes exposes the services api declares on an rpc.Handler, one +// function per service. It is its own package so the tests that post as a +// client of another release (internal/cli) answer through the same routes the +// app does. +// +// A service method nothing here routes cannot be reached over /rpc/, and the +// ones kept out are kept out on purpose: they exist for Go callers inside the +// process. Startup wiring (the Set* setters, store.Close) would rewire or end +// what the app runs on; the hooks' state stream and a worker's answer arrive +// over /hook and /mod/*, and called here would act for another session; +// terminal.CloseBy, the mod's command queue and terminal.SubmitPrompt skip the +// checks spawn.Close, spawn.Control and the relay make; terminal.SessionAccount +// returns a session's credentials; and a dropped file's bytes go to /drop, +// outside the JSON envelope. +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/spawn" + "github.com/lichdotdev/lich/internal/themes" +) + +// Themes exposes the themes service as api declares it. +func Themes(d *rpc.Handler, s *themes.Service) { + rpc.Handle(d, api.ThemesList, func(context.Context, api.None) ([]api.Theme, error) { return s.List() }) + rpc.Handle(d, api.ThemesListBroken, func(context.Context, api.None) ([]api.BrokenTheme, error) { return s.ListBroken() }) + rpc.Handle(d, api.ThemesImport, func(_ context.Context, o api.ImportThemeOptions) (api.ThemeImportResult, error) { + return s.Import(o.Path, o.Overwrite) + }) + rpc.Handle(d, api.ThemesInstallFromGit, func(_ context.Context, o api.InstallThemesOptions) (api.ThemeGitResult, error) { + return s.InstallFromGit(o.URL, o.Overwrite) + }) + rpc.Handle(d, api.ThemesUpdateFromGit, func(_ context.Context, o api.UpdateThemeOptions) (api.ThemeGitResult, error) { + return s.UpdateFromGit(o.ID) + }) + rpc.Handle(d, api.ThemesRemove, func(_ context.Context, o api.RemoveThemeOptions) (api.None, error) { + return api.None{}, s.Remove(o.ID) + }) + rpc.Handle(d, api.ThemesSaveTemplate, func(_ context.Context, o api.SaveThemeTemplateOptions) (api.None, error) { + return api.None{}, s.SaveTemplate(o.Path) + }) + rpc.Handle(d, api.ThemesSelection, func(context.Context, api.None) (api.ThemeSelection, error) { return s.Selection() }) + rpc.Handle(d, api.ThemesSelect, func(_ context.Context, o api.SelectThemeOptions) (api.None, error) { + return api.None{}, s.Select(o) + }) + rpc.Handle(d, api.ThemesSetFollowSystem, func(_ context.Context, o api.FollowSystemOptions) (api.None, error) { + return api.None{}, s.SetFollowSystem(o.On) + }) +} + +// Spawn exposes the spawn service as api declares it. CloseFinishedWorker +// stays out: it is the relay's, and closes a worker with none of Close's checks. +func Spawn(d *rpc.Handler, s *spawn.Service) { + rpc.Handle(d, api.SpawnOpen, func(_ context.Context, o api.OpenSessionOptions) (api.OpenedSession, error) { return s.Open(o) }) + rpc.Handle(d, api.SpawnOpenSubagent, func(_ context.Context, o api.OpenSubagentOptions) (api.OpenedSession, error) { + return s.OpenSubagent(o) + }) + rpc.Handle(d, api.SpawnClose, func(_ context.Context, o api.CloseSessionOptions) (api.ClosedSession, error) { return s.Close(o) }) + rpc.Handle(d, api.SpawnControl, s.Control) + rpc.Handle(d, api.SpawnAsk, s.Ask) + rpc.Handle(d, api.SpawnWorktrees, func(_ context.Context, o api.WorktreesOptions) ([]api.WorktreeCheckout, error) { + return s.Worktrees(o) + }) + rpc.Handle(d, api.SpawnFolders, func(_ context.Context, o api.FoldersOptions) ([]api.SessionFolder, error) { return s.Folders(o) }) + rpc.Handle(d, api.SpawnFile, func(_ context.Context, o api.FileSessionOptions) (api.FiledSession, error) { return s.File(o) }) + rpc.Handle(d, api.SpawnRenameFolder, func(_ context.Context, o api.RenameFolderOptions) (api.RenamedFolder, error) { + return s.RenameFolder(o) + }) + rpc.Handle(d, api.SpawnColorFolder, func(_ context.Context, o api.ColorFolderOptions) (api.ColoredFolder, error) { + return s.ColorFolder(o) + }) + rpc.Handle(d, api.SpawnRename, func(_ context.Context, o api.RenameSessionOptions) (api.RenamedSession, error) { return s.Rename(o) }) +} diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go new file mode 100644 index 000000000..89bbbe633 --- /dev/null +++ b/internal/routes/routes_test.go @@ -0,0 +1,128 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/spawn" + "github.com/lichdotdev/lich/internal/themes" + "github.com/lichdotdev/lich/internal/xdgprofile" +) + +func TestHandleThemesCoversEveryThemesMethod(t *testing.T) { + d := rpc.New() + Themes(d, themes.NewInDir(t.TempDir())) + assertHandled(t, d, "themes.") +} + +// The services below are never reached: a handled call stops at its decoding. +func TestHandleRelayCoversEveryRelayMethod(t *testing.T) { + d := rpc.New() + Relay(d, nil) + assertHandled(t, d, "relay.") +} + +func TestHandleSystemCoversEverySystemMethod(t *testing.T) { + d := rpc.New() + System(d, nil) + assertHandled(t, d, "system.") +} + +func TestHandleTrayCoversEveryTrayMethod(t *testing.T) { + d := rpc.New() + Tray(d, nil) + assertHandled(t, d, "tray.") +} + +func TestHandleStoreCoversEveryStoreMethod(t *testing.T) { + d := rpc.New() + Store(d, nil) + assertHandled(t, d, "store.") +} + +func TestHandleAppServicesCoverEveryMethod(t *testing.T) { + for prefix, route := range map[string]func(*rpc.Handler){ + "drop.": func(d *rpc.Handler) { Drop(d, nil) }, + "patchnotes.": func(d *rpc.Handler) { PatchNotes(d, nil) }, + "appupdate.": func(d *rpc.Handler) { AppUpdate(d, nil) }, + "agentplugin.": func(d *rpc.Handler) { AgentPlugin(d, nil) }, + "providers.": func(d *rpc.Handler) { Providers(d, nil) }, + "terminal.": func(d *rpc.Handler) { Terminal(d, nil) }, + "project.": func(d *rpc.Handler) { Project(d, nil) }, + } { + d := rpc.New() + route(d) + assertHandled(t, d, prefix) + } +} + +func TestHandleProfilesCoverEveryProfileMethod(t *testing.T) { + claude := rpc.New() + Profiles(claude, api.ClaudeProfiles, nil) + ProfileLogin(claude, api.ClaudeProfileLogin, nil) + ProfilePluginCalls(claude, api.ClaudeProfilePlugins, nil) + ClaudeLoginCode(claude, nil) + assertHandled(t, claude, "claudeprofile.") + + codex := rpc.New() + Profiles(codex, api.CodexProfiles, nil) + ProfileLogin(codex, api.CodexProfileLogin, nil) + ProfilePluginCalls(codex, api.CodexProfilePlugins, nil) + assertHandled(t, codex, "codexprofile.") + + omp := rpc.New() + Profiles(omp, api.OMPProfiles, nil) + assertHandled(t, omp, "ompprofile.") + + quotas := rpc.New() + Quota(quotas, nil) + assertHandled(t, quotas, "quota.") +} + +// main.go routes each xdgprofile provider through api.XDGProfiles by its id; +// a provider api has no entry for would be routed under no name at all. +func TestEveryXDGProfileProviderIsDeclared(t *testing.T) { + for _, provider := range xdgprofile.Providers() { + m, ok := api.XDGProfiles[provider.ID] + if !ok { + t.Errorf("api.XDGProfiles has no %q", provider.ID) + continue + } + d := rpc.New() + Profiles(d, m, nil) + assertHandled(t, d, provider.ID+"profile.") + } +} + +// assertHandled fails on a method under prefix that api declares and no route +// of d answers, which a client would only meet as a 404. A body that is no +// options object stops a handled call at its decoding (400), before the +// service runs; an unhandled one is unknown (404). +func assertHandled(t *testing.T, d *rpc.Handler, prefix string) { + t.Helper() + seen := 0 + for name := range api.Methods() { + if !strings.HasPrefix(name, prefix) { + continue + } + seen++ + rec := httptest.NewRecorder() + d.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/rpc/"+name, strings.NewReader(`["not options"]`))) + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: want 400 from its route, got %d %q", name, rec.Code, rec.Body.String()) + } + } + if seen == 0 { + t.Fatalf("api declares no %s method: the scan no longer sees them", prefix) + } +} + +func TestHandleSpawnCoversEverySpawnMethod(t *testing.T) { + d := rpc.New() + Spawn(d, spawn.New(nil, nil, nil, nil)) + assertHandled(t, d, "spawn.") +} diff --git a/internal/routes/store.go b/internal/routes/store.go new file mode 100644 index 000000000..743faec02 --- /dev/null +++ b/internal/routes/store.go @@ -0,0 +1,106 @@ +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/store" +) + +// Store exposes the store service as api declares it. +func Store(d *rpc.Handler, s *store.Service) { + storeProjects(d, s) + storeSessions(d, s) + storeSessionMarks(d, s) + rpc.Handle(d, api.StoreCostTotals, func(_ context.Context, o api.CostTotalsOptions) (api.CostReport, error) { return s.CostTotals(o) }) + rpc.Handle(d, api.StoreGetSetting, func(_ context.Context, o api.GetSettingOptions) (string, error) { + return s.GetSetting(o.Key, o.ProjectID) + }) + rpc.Handle(d, api.StoreSetSetting, func(_ context.Context, o api.SetSettingOptions) (api.None, error) { + return api.None{}, s.SetSetting(o.Key, o.ProjectID, o.Value) + }) +} + +func storeProjects(d *rpc.Handler, s *store.Service) { + rpc.Handle(d, api.StoreLoadState, func(context.Context, api.None) ([]api.Project, error) { return s.LoadState() }) + rpc.Handle(d, api.StoreAddProject, func(_ context.Context, o api.AddProjectOptions) (api.None, error) { + return api.None{}, s.AddProject(o.ID, o.Name, o.Path) + }) + rpc.Handle(d, api.StoreCloseProject, func(_ context.Context, o api.ProjectIDOptions) (api.None, error) { + return api.None{}, s.CloseProject(o.ID) + }) + rpc.Handle(d, api.StoreReorderProjects, func(_ context.Context, o api.ReorderProjectsOptions) (api.None, error) { + return api.None{}, s.ReorderProjects(o.IDs) + }) + rpc.Handle(d, api.StoreRecentProjects, func(_ context.Context, o api.SearchTermOptions) ([]api.RecentProject, error) { + return s.RecentProjects(o.Term) + }) + rpc.Handle(d, api.StoreClosedProjectCount, func(_ context.Context, o api.SearchTermOptions) (int, error) { + return s.ClosedProjectCount(o.Term) + }) + rpc.Handle(d, api.StoreRenameFolder, func(_ context.Context, o api.RenameProjectFolderOptions) ([]string, error) { + return s.RenameFolder(o.ProjectID, o.From, o.To) + }) +} + +func storeSessions(d *rpc.Handler, s *store.Service) { + rpc.Handle(d, api.StoreAddSession, func(_ context.Context, o api.AddSessionOptions) (api.None, error) { + return api.None{}, s.AddSession(o.ProjectID, o.SessionID, o.Label, o.Kind, o.Path, o.NextSeq, o.Sandbox) + }) + rpc.Handle(d, api.StoreAddSessionInFolder, func(_ context.Context, o api.AddSessionInFolderOptions) (api.None, error) { + return api.None{}, s.AddSessionInFolder(o.ProjectID, o.SessionID, o.Label, o.Kind, o.Path, o.NextSeq, o.Sandbox, o.Folder) + }) + rpc.Handle(d, api.StoreAddSessionFrom, func(_ context.Context, o api.AddSessionFromOptions) (api.None, error) { + return api.None{}, s.AddSessionFrom(o.ProjectID, o.SessionID, o.Label, o.Kind, o.Path, o.NextSeq, o.OriginID, o.OriginLabel, o.Folder) + }) + rpc.Handle(d, api.StoreSetActiveSession, func(_ context.Context, o api.ActiveSessionOptions) (api.None, error) { + return api.None{}, s.SetActiveSession(o.ProjectID, o.SessionID) + }) + rpc.Handle(d, api.StoreCloseSession, func(_ context.Context, o api.ParkSessionOptions) (api.None, error) { + return api.None{}, s.CloseSession(o.ProjectID, o.SessionID, o.ActiveID) + }) + rpc.Handle(d, api.StoreReopenSession, func(_ context.Context, o api.ReopenSessionOptions) (*api.Session, error) { + return s.ReopenSession(o.SessionID, o.NewSessionID) + }) + rpc.Handle(d, api.StoreReopenWorktreeSession, func(_ context.Context, o api.ReopenWorktreeSessionOptions) (*api.Session, error) { + return s.ReopenWorktreeSession(o.ProjectID, o.Path, o.NewSessionID) + }) + rpc.Handle(d, api.StorePurgeWorktreeSessions, func(_ context.Context, o api.WorktreeSessionsOptions) (api.None, error) { + return api.None{}, s.PurgeWorktreeSessions(o.ProjectID, o.Path) + }) + rpc.Handle(d, api.StoreClosedSessions, func(_ context.Context, o api.SearchTermOptions) (api.ClosedHistory, error) { + return s.ClosedSessions(o.Term) + }) + rpc.Handle(d, api.StoreExternalSessions, func(context.Context, api.None) ([]api.ExternalSession, error) { return s.ExternalSessions() }) + rpc.Handle(d, api.StoreAdoptExternalSession, func(_ context.Context, o api.AdoptExternalSessionOptions) (api.None, error) { + return api.None{}, s.AdoptExternalSession(o.ProjectID, o.SessionID, o.Kind, o.Path, o.ProviderSessionID, o.Label) + }) + rpc.Handle(d, api.StoreForgetSession, func(_ context.Context, o api.SessionIDOptions) (api.None, error) { + return api.None{}, s.ForgetSession(o.ID) + }) + rpc.Handle(d, api.StoreMarkForked, func(_ context.Context, o api.SessionIDOptions) (api.None, error) { + return api.None{}, s.MarkForked(o.ID) + }) + rpc.Handle(d, api.StoreReorderSessions, func(_ context.Context, o api.ReorderSessionsOptions) (api.None, error) { + return api.None{}, s.ReorderSessions(o.ProjectID, o.IDs) + }) +} + +func storeSessionMarks(d *rpc.Handler, s *store.Service) { + rpc.Handle(d, api.StoreRenameSession, func(_ context.Context, o api.LabelSessionOptions) (api.None, error) { + return api.None{}, s.RenameSession(o.SessionID, o.Label) + }) + rpc.Handle(d, api.StoreSetSessionColor, func(_ context.Context, o api.SessionColorOptions) (api.None, error) { + return api.None{}, s.SetSessionColor(o.SessionID, o.Color) + }) + rpc.Handle(d, api.StoreSetSessionFolder, func(_ context.Context, o api.SessionFolderOptions) (api.None, error) { + return api.None{}, s.SetSessionFolder(o.SessionID, o.Folder) + }) + rpc.Handle(d, api.StoreSetSessionPinned, func(_ context.Context, o api.SessionPinnedOptions) (api.None, error) { + return api.None{}, s.SetSessionPinned(o.SessionID, o.Pinned) + }) + rpc.Handle(d, api.StoreSetSessionUnread, func(_ context.Context, o api.SessionUnreadOptions) (api.None, error) { + return api.None{}, s.SetSessionUnread(o.SessionID, o.Unread) + }) +} diff --git a/internal/routes/system.go b/internal/routes/system.go new file mode 100644 index 000000000..eabc5daed --- /dev/null +++ b/internal/routes/system.go @@ -0,0 +1,51 @@ +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/system" + "github.com/lichdotdev/lich/internal/tray" +) + +// System exposes the system service as api declares it. +func System(d *rpc.Handler, s *system.Service) { + rpc.Handle(d, api.SystemShowWindow, func(_ context.Context, o api.ShowWindowOptions) (api.None, error) { + return api.None{}, s.ShowWindow(o) + }) + rpc.Handle(d, api.SystemQuit, func(_ context.Context, o api.QuitOptions) (api.None, error) { return api.None{}, s.Quit(o) }) + rpc.Handle(d, api.SystemAttachNative, func(_ context.Context, o api.AttachNativeOptions) (api.None, error) { + s.AttachNative(o) + return api.None{}, nil + }) + rpc.Handle(d, api.SystemDiagnostics, func(context.Context, api.None) (api.Diagnostics, error) { return s.Diagnostics(), nil }) + rpc.Handle(d, api.SystemNotify, func(_ context.Context, o api.NotifyOptions) (api.None, error) { + return api.None{}, s.Notify(o.Summary, o.Detail) + }) + rpc.Handle(d, api.SystemOpenExternal, func(_ context.Context, o api.OpenExternalOptions) (api.None, error) { + return api.None{}, s.OpenExternal(o.URL) + }) + rpc.Handle(d, api.SystemOpenFile, func(_ context.Context, o api.OpenFileOptions) (api.None, error) { + return api.None{}, s.OpenFile(o.Dir, o.Rel) + }) + rpc.Handle(d, api.SystemOpenFolder, func(_ context.Context, o api.OpenFolderOptions) (api.None, error) { + return api.None{}, s.OpenFolder(o.Dir) + }) + rpc.Handle(d, api.SystemOpenFolderInEditor, func(_ context.Context, o api.OpenFolderOptions) (string, error) { + return s.OpenFolderInEditor(o.Dir) + }) + rpc.Handle(d, api.SystemRevealLog, func(context.Context, api.None) (api.None, error) { return api.None{}, s.RevealLog() }) + rpc.Handle(d, api.SystemSandboxBackend, func(context.Context, api.None) (string, error) { return s.SandboxBackend(), nil }) + rpc.Handle(d, api.SystemSSHAgentKeys, func(context.Context, api.None) ([]string, error) { return s.SSHAgentKeys(), nil }) + rpc.Handle(d, api.SystemTakeUncleanExit, func(context.Context, api.None) (bool, error) { return s.TakeUncleanExit(), nil }) +} + +// Tray exposes the tray as api declares it. tray.Labels keeps its own type for +// its methods; the conversion holds its fields to api's. +func Tray(d *rpc.Handler, t *tray.Tray) { + rpc.Handle(d, api.TraySetLabels, func(_ context.Context, o api.TrayLabels) (api.None, error) { + t.SetLabels(tray.Labels(o)) + return api.None{}, nil + }) +} diff --git a/internal/routes/terminal.go b/internal/routes/terminal.go new file mode 100644 index 000000000..15b4f0af2 --- /dev/null +++ b/internal/routes/terminal.go @@ -0,0 +1,49 @@ +package routes + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/terminal" +) + +// Terminal exposes the terminal service as api declares it. A close on behalf +// of another session and the mod's command queue stay out: they skip the +// checks spawn.Close and spawn.Control make. +func Terminal(d *rpc.Handler, s *terminal.Service) { + rpc.Handle(d, api.TerminalStart, func(_ context.Context, o api.StartOptions) (api.None, error) { + return api.None{}, s.Start(o.ID, o.ProjectID, o.Cwd, o.Kind, o.Resume, o.Fork, o.Cols, o.Rows) + }) + rpc.Handle(d, api.TerminalClose, func(_ context.Context, o api.SessionIDOptions) (api.None, error) { return api.None{}, s.Close(o.ID) }) + rpc.Handle(d, api.TerminalWrite, func(_ context.Context, o api.WriteOptions) (api.None, error) { + return api.None{}, s.Write(o.ID, o.Data) + }) + rpc.Handle(d, api.TerminalResize, func(_ context.Context, o api.ResizeOptions) (api.None, error) { + return api.None{}, s.Resize(o.ID, o.Cols, o.Rows) + }) + rpc.Handle(d, api.TerminalSetVisible, func(_ context.Context, o api.SetVisibleOptions) (api.None, error) { + return api.None{}, s.SetVisible(o.ID, o.Visible) + }) + rpc.Handle(d, api.TerminalReplay, func(_ context.Context, o api.SessionIDOptions) (string, error) { return s.Replay(o.ID) }) + rpc.Handle(d, api.TerminalLive, func(_ context.Context, o api.SessionIDOptions) (bool, error) { return s.Live(o.ID), nil }) + rpc.Handle(d, api.TerminalLiveCount, func(context.Context, api.None) (int, error) { return s.LiveCount(), nil }) + rpc.Handle(d, api.TerminalReady, func(_ context.Context, o api.SessionIDOptions) (bool, error) { return s.Ready(o.ID), nil }) + rpc.Handle(d, api.TerminalHandsOn, func(_ context.Context, o api.SessionIDOptions) (int64, error) { return s.HandsOn(o.ID) }) + rpc.Handle(d, api.TerminalLastTurnSaid, func(_ context.Context, o api.SessionIDOptions) (api.LastSaid, error) { + return s.LastTurnSaid(o.ID) + }) + rpc.Handle(d, api.TerminalTurnStates, func(context.Context, api.None) ([]api.TurnState, error) { return s.TurnStates(), nil }) + rpc.Handle(d, api.TerminalContinueIn, func(_ context.Context, o api.ContinueInOptions) (api.None, error) { + return api.None{}, s.ContinueIn(o.ID, o.Profile) + }) + rpc.Handle(d, api.TerminalSearchTranscripts, func(_ context.Context, o api.SearchTranscriptsOptions) ([]api.TranscriptMatch, error) { + return s.SearchTranscripts(o.IDs, o.Query), nil + }) + rpc.Handle(d, api.TerminalWorkdirMissing, func(_ context.Context, o api.WorkdirOptions) (bool, error) { + return s.WorkdirMissing(o.Cwd), nil + }) + rpc.Handle(d, api.TerminalResumeAvailable, func(_ context.Context, o api.ResumeAvailableOptions) (bool, error) { + return s.ResumeAvailable(o.Kind, o.ProviderSessionID, o.Cwd), nil + }) +} diff --git a/internal/rpc/handle_test.go b/internal/rpc/handle_test.go new file mode 100644 index 000000000..89ea76737 --- /dev/null +++ b/internal/rpc/handle_test.go @@ -0,0 +1,94 @@ +package rpc + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lichdotdev/lich/api" +) + +var ( + echo = api.Method[pair, pair]{Name: "typed.Echo"} + silent = api.Method[pair, api.None]{Name: "typed.Silent"} + traced = api.Method[api.None, string]{Name: "typed.Traced"} +) + +func newTypedHandler() *Handler { + h := New() + Handle(h, echo, func(_ context.Context, p pair) (pair, error) { + if p.A == "fail" { + return pair{}, errors.New("boom") + } + return p, nil + }) + Handle(h, silent, func(context.Context, pair) (api.None, error) { return api.None{}, nil }) + Handle(h, traced, func(ctx context.Context, _ api.None) (string, error) { + tag, _ := ctx.Value(ctxKey{}).(string) + return tag, nil + }) + return h +} + +func TestHandleDecodesTheOptionsObjectByName(t *testing.T) { + rec := call(t, newTypedHandler(), "typed.Echo", `[{"b":7,"a":"x","unknown":true}]`) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"a":"x","b":7}` { + t.Fatalf("got %d %q", rec.Code, rec.Body.String()) + } +} + +// An empty envelope is the zero options: what a client from before the +// method took any options sends. +func TestHandleReadsAnEmptyEnvelopeAsZeroOptions(t *testing.T) { + for _, body := range []string{`[]`, ``} { + rec := call(t, newTypedHandler(), "typed.Echo", body) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"a":"","b":0}` { + t.Errorf("body %q: got %d %q", body, rec.Code, rec.Body.String()) + } + } +} + +func TestHandleAnswersNoneAsNull(t *testing.T) { + rec := call(t, newTypedHandler(), "typed.Silent", `[{"a":"x"}]`) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "null" { + t.Fatalf("got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestHandleRefusesWhatIsNotOneOptionsObject(t *testing.T) { + for _, body := range []string{`["x", 2]`, `["x"]`, `{"a":"x"}`, `[{"a":"x"},{"a":"y"}]`} { + if rec := call(t, newTypedHandler(), "typed.Echo", body); rec.Code != http.StatusBadRequest { + t.Errorf("body %s: want 400, got %d %q", body, rec.Code, rec.Body.String()) + } + } +} + +func TestHandleReportsTheServiceError(t *testing.T) { + rec := call(t, newTypedHandler(), "typed.Echo", `[{"a":"fail"}]`) + if rec.Code != http.StatusInternalServerError || !strings.Contains(rec.Body.String(), "boom") { + t.Fatalf("got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestHandleHandsOverTheRequestContext(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/rpc/typed.Traced", strings.NewReader(`[]`)) + req = req.WithContext(context.WithValue(req.Context(), ctxKey{}, "req-7")) + rec := httptest.NewRecorder() + newTypedHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `"req-7"` { + t.Fatalf("got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestHandleRefusesASecondHandlerForOneMethod(t *testing.T) { + h := newTypedHandler() + defer func() { + if recover() == nil { + t.Error("a second Handle of typed.Echo did not panic") + } + }() + Handle(h, echo, func(_ context.Context, p pair) (pair, error) { return p, nil }) +} diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go index 62591647e..e3fc1da9f 100644 --- a/internal/rpc/rpc.go +++ b/internal/rpc/rpc.go @@ -1,59 +1,93 @@ -// Package rpc exposes the app's Go services to the window over plain HTTP -// on the local transport listener: the window is its own process and never -// imports internal/ (docs/native-window.md). One POST per call: +// Package rpc exposes the app's Go services to the window, `lich` and `lich +// mcp` over plain HTTP on the local transport listener: the window is its own +// process and never imports internal/ (docs/native-window.md). One POST per +// call: // -// POST /rpc/.?token=... body: JSON array of arguments +// POST /rpc/.?token=... body: [options] or [] // → 200 with the method's JSON result (null when it only returns error) // → 4xx/5xx with {"error": "..."} otherwise // -// Dispatch is reflection over explicitly registered services — the net/rpc -// pattern — so a new service method is exposed by registration alone. A method -// whose first parameter is a context.Context gets the request's, which is done -// when the caller hangs up; the JSON array carries the arguments after it. Token -// auth is applied by the transport mount, not here. +// Only a method declared in api and exposed with Handle answers; the compiler +// holds the handler to the method's options and result types. Its context is +// done when the caller hangs up. Token auth is applied by the transport mount, +// not here. package rpc import ( "context" "encoding/json" + "errors" "fmt" "io" "log/slog" "net/http" - "reflect" "strings" + + "github.com/lichdotdev/lich/api" ) -// bodyLimit bounds one RPC request body; arguments are ids, paths and small +// bodyLimit bounds one RPC request body; options are ids, paths and small // strings, so anything larger is malformed or hostile. const bodyLimit = 1 << 20 -var ( - errType = reflect.TypeFor[error]() - contextType = reflect.TypeFor[context.Context]() -) - type Handler struct { - services map[string]reflect.Value - denied map[string]bool + routes map[string]route } +// route answers one api method: the call's result, or badArguments when the +// body does not decode into its options. +type route func(ctx context.Context, body []byte) (any, error) + +type badArguments struct{ err error } + +func (b badArguments) Error() string { return b.err.Error() } + func New() *Handler { - return &Handler{ - services: make(map[string]reflect.Value), - denied: make(map[string]bool), - } + return &Handler{routes: make(map[string]route)} } -// Register exposes every exported method of svc under name.. -func (h *Handler) Register(name string, svc any) { - h.services[name] = reflect.ValueOf(svc) +// Handle exposes fn as m. +func Handle[Req, Resp any](h *Handler, m api.Method[Req, Resp], fn func(context.Context, Req) (Resp, error)) { + if _, dup := h.routes[m.Name]; dup { + panic(fmt.Sprintf("rpc: %s handled twice", m.Name)) + } + h.routes[m.Name] = func(ctx context.Context, body []byte) (any, error) { + req, err := decodeOptions[Req](body) + if err != nil { + return nil, badArguments{err} + } + resp, err := fn(ctx, req) + if err != nil { + return nil, err + } + if _, empty := any(resp).(api.None); empty { + return nil, nil + } + return resp, nil + } } -// Deny hides one method ("store.Close") from dispatch — for methods that are -// public for Go callers but must not be reachable from the window. -func (h *Handler) Deny(method string) { - h.denied[method] = true +// decodeOptions reads the envelope api.Method.Args writes. An empty one is +// the zero options, which is also what a client from before the method took +// any sends. +func decodeOptions[Req any](body []byte) (Req, error) { + var req Req + var raw []json.RawMessage + if len(body) > 0 { + if err := json.Unmarshal(body, &raw); err != nil { + return req, fmt.Errorf("arguments must be a JSON array: %w", err) + } + } + switch len(raw) { + case 0: + return req, nil + case 1: + if err := json.Unmarshal(raw[0], &req); err != nil { + return req, fmt.Errorf("options: %w", err) + } + return req, nil + } + return req, fmt.Errorf("want one options object, got %d arguments", len(raw)) } // ServeHTTP handles one call. @@ -62,96 +96,39 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusMethodNotAllowed, "POST only") return } - name := strings.TrimPrefix(r.URL.Path, "/rpc/") - method, err := h.lookup(name) - if err != nil { - slog.Warn("rpc: unknown method", "method", name, "err", err) - writeError(w, http.StatusNotFound, err.Error()) + route, ok := h.routes[name] + if !ok { + slog.Warn("rpc: unknown method", "method", name) + writeError(w, http.StatusNotFound, fmt.Sprintf("unknown method %q", name)) return } - body, err := io.ReadAll(io.LimitReader(r.Body, bodyLimit)) if err != nil { slog.Warn("rpc: unreadable body", "method", name, "err", err) writeError(w, http.StatusBadRequest, "unreadable body") return } - args, err := decodeArgs(r.Context(), method.Type(), body) - if err != nil { + result, err := route(r.Context(), body) + var bad badArguments + if errors.As(err, &bad) { slog.Warn("rpc: bad arguments", "method", name, "err", err) writeError(w, http.StatusBadRequest, err.Error()) return } - - writeResult(w, name, method.Call(args)) -} - -func (h *Handler) lookup(name string) (reflect.Value, error) { - if h.denied[name] { - return reflect.Value{}, fmt.Errorf("method %q not available", name) - } - service, methodName, ok := strings.Cut(name, ".") - if !ok { - return reflect.Value{}, fmt.Errorf("malformed method %q", name) - } - svc, ok := h.services[service] - if !ok { - return reflect.Value{}, fmt.Errorf("unknown service %q", service) - } - method := svc.MethodByName(methodName) - if !method.IsValid() { - return reflect.Value{}, fmt.Errorf("unknown method %q", name) - } - return method, nil -} - -// decodeArgs unmarshals the JSON argument array positionally into the -// method's parameter types, handing ctx to a leading context.Context. -func decodeArgs(ctx context.Context, t reflect.Type, body []byte) ([]reflect.Value, error) { - var raw []json.RawMessage - if len(body) > 0 { - if err := json.Unmarshal(body, &raw); err != nil { - return nil, fmt.Errorf("arguments must be a JSON array: %w", err) - } - } - args := make([]reflect.Value, 0, t.NumIn()) - if t.NumIn() > 0 && t.In(0) == contextType { - args = append(args, reflect.ValueOf(ctx)) - } - skipped := len(args) - if len(raw) != t.NumIn()-skipped { - return nil, fmt.Errorf("want %d arguments, got %d", t.NumIn()-skipped, len(raw)) - } - for i, arg := range raw { - value := reflect.New(t.In(skipped + i)) - if err := json.Unmarshal(arg, value.Interface()); err != nil { - return nil, fmt.Errorf("argument %d: %w", i, err) - } - args = append(args, value.Elem()) - } - return args, nil + writeReply(w, name, result, err) } -// writeResult maps Go returns onto the response: a trailing non-nil error is -// a 500, otherwise the first non-error value (or null) is the 200 body. -// Every service error is also logged with the method name — the RPC is the -// single funnel every client-triggered failure passes through, which makes -// this the one line that turns them into an audit trail. -func writeResult(w http.ResponseWriter, method string, results []reflect.Value) { - var payload any - for _, result := range results { - if result.Type().Implements(errType) { - if err, _ := result.Interface().(error); err != nil { - slog.Warn("rpc: call failed", "method", method, "err", err) - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - continue - } - if payload == nil { - payload = result.Interface() - } +// writeReply maps a call's outcome onto the response: an error is a 500, +// otherwise the result (or null) is the 200 body. Every service error is also +// logged with the method name — the RPC is the single funnel every +// client-triggered failure passes through, which makes this the one line that +// turns them into an audit trail. +func writeReply(w http.ResponseWriter, method string, payload any, err error) { + if err != nil { + slog.Warn("rpc: call failed", "method", method, "err", err) + writeError(w, http.StatusInternalServerError, err.Error()) + return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(payload); err != nil { diff --git a/internal/rpc/rpc_test.go b/internal/rpc/rpc_test.go index 4024bb856..da2afe422 100644 --- a/internal/rpc/rpc_test.go +++ b/internal/rpc/rpc_test.go @@ -1,46 +1,19 @@ package rpc import ( - "context" - "encoding/json" - "errors" - "fmt" "net/http" "net/http/httptest" "strings" "testing" ) -type fakeService struct{} - type pair struct { A string `json:"a"` B int `json:"b"` } -func (fakeService) Add(a, b int) int { return a + b } -func (fakeService) Fail() error { return errors.New("boom") } -func (fakeService) Both(ok bool) (string, error) { return "yes", nil } -func (fakeService) Struct(p pair) (pair, error) { return p, nil } -func (fakeService) Nothing(id string) error { return nil } -func (fakeService) Slices(ids []string) (int, bool) { return len(ids), true } - type ctxKey struct{} -// Traced answers with what the request's context carries, which proves the -// context handed in is the request's own and not a stand-in. -func (fakeService) Traced(ctx context.Context, n int) (string, error) { - tag, _ := ctx.Value(ctxKey{}).(string) - return fmt.Sprintf("%s:%d", tag, n), nil -} - -func newTestHandler() *Handler { - h := New() - h.Register("fake", fakeService{}) - h.Deny("fake.Nothing") - return h -} - func call(t *testing.T, h *Handler, method, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/rpc/"+method, strings.NewReader(body)) @@ -49,103 +22,23 @@ func call(t *testing.T, h *Handler, method, body string) *httptest.ResponseRecor return rec } -func TestCallWithResult(t *testing.T) { - rec := call(t, newTestHandler(), "fake.Add", `[2, 3]`) - if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "5" { - t.Fatalf("got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestCallReturningError(t *testing.T) { - rec := call(t, newTestHandler(), "fake.Fail", `[]`) - if rec.Code != http.StatusInternalServerError { - t.Fatalf("want 500, got %d", rec.Code) - } - var body map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body["error"] != "boom" { - t.Fatalf("error body: %q", rec.Body.String()) - } -} - -func TestValueAndNilError(t *testing.T) { - rec := call(t, newTestHandler(), "fake.Both", `[true]`) - if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `"yes"` { - t.Fatalf("got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestStructRoundTrip(t *testing.T) { - rec := call(t, newTestHandler(), "fake.Struct", `[{"a":"x","b":7}]`) - var p pair - if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || p.A != "x" || p.B != 7 { - t.Fatalf("round trip: %q", rec.Body.String()) - } -} - -func TestErrorOnlyMethodDenied(t *testing.T) { - rec := call(t, newTestHandler(), "fake.Nothing", `["id"]`) - if rec.Code != http.StatusNotFound { - t.Fatalf("denied method: want 404, got %d", rec.Code) - } -} - -func TestUnknownServiceAndMethod(t *testing.T) { - if rec := call(t, newTestHandler(), "nope.Add", `[]`); rec.Code != http.StatusNotFound { - t.Fatalf("unknown service: %d", rec.Code) - } - if rec := call(t, newTestHandler(), "fake.Nope", `[]`); rec.Code != http.StatusNotFound { - t.Fatalf("unknown method: %d", rec.Code) - } - if rec := call(t, newTestHandler(), "malformed", `[]`); rec.Code != http.StatusNotFound { - t.Fatalf("malformed name: %d", rec.Code) - } -} - -func TestArgumentValidation(t *testing.T) { - if rec := call(t, newTestHandler(), "fake.Add", `[1]`); rec.Code != http.StatusBadRequest { - t.Fatalf("arity: %d", rec.Code) - } - if rec := call(t, newTestHandler(), "fake.Add", `{"a":1}`); rec.Code != http.StatusBadRequest { - t.Fatalf("non-array: %d", rec.Code) - } - if rec := call(t, newTestHandler(), "fake.Add", `["x", 2]`); rec.Code != http.StatusBadRequest { - t.Fatalf("type mismatch: %d", rec.Code) - } -} - -func TestMultipleNonErrorReturnsUseFirst(t *testing.T) { - rec := call(t, newTestHandler(), "fake.Slices", `[["a","b"]]`) - if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "2" { - t.Fatalf("got %d %q", rec.Code, rec.Body.String()) +func TestAMethodNoRouteAnswersIsUnknown(t *testing.T) { + for _, name := range []string{"typed.Nope", "nope.Echo", "malformed"} { + if rec := call(t, newTypedHandler(), name, `[]`); rec.Code != http.StatusNotFound { + t.Errorf("%s: want 404, got %d", name, rec.Code) + } } } // Only a POST is a call, and no CORS header is answered: a page in a browser // holding the token cannot read a reply cross-origin. func TestOnlyPostIsACall(t *testing.T) { - h := newTestHandler() + h := newTypedHandler() for _, method := range []string{http.MethodOptions, http.MethodGet} { rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(method, "/rpc/fake.Add", nil)) + h.ServeHTTP(rec, httptest.NewRequest(method, "/rpc/typed.Echo", nil)) if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Access-Control-Allow-Origin") != "" { t.Fatalf("%s: %d %v", method, rec.Code, rec.Header()) } } } - -func TestALeadingContextIsTheRequestsAndTakesNoArgument(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/rpc/fake.Traced", strings.NewReader(`[4]`)) - req = req.WithContext(context.WithValue(req.Context(), ctxKey{}, "req")) - rec := httptest.NewRecorder() - newTestHandler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `"req:4"` { - t.Fatalf("got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestALeadingContextIsNotCountedAsAnArgument(t *testing.T) { - rec := call(t, newTestHandler(), "fake.Traced", `[{}, 4]`) - if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "want 1 arguments, got 2") { - t.Fatalf("got %d %q", rec.Code, rec.Body.String()) - } -} diff --git a/internal/singleton/singleton.go b/internal/singleton/singleton.go index e2a62ea94..a8612917c 100644 --- a/internal/singleton/singleton.go +++ b/internal/singleton/singleton.go @@ -20,6 +20,8 @@ import ( "strconv" "strings" "time" + + "github.com/lichdotdev/lich/api" ) // Info is the running lich recorded in runtime.json. It lets install.sh reach a @@ -177,19 +179,16 @@ func Ping(port int, token string) bool { return resp.StatusCode == http.StatusNoContent } -// showWindowMethod is the running lich's system.ShowWindow, posted with its -// options object. -const ( - showWindowMethod = "system.ShowWindow" - showWindowBody = "[{}]" -) +// showWindowBody is api.SystemShowWindow's empty options object, written out: +// an older running lich reads it by position and wants exactly one. +const showWindowBody = "[{}]" // Show asks the running lich to open its window, or to bring the open one to // the front. An older lich answers it with an error: it has no such call, and // always has a window open, which the caller then focuses the old way. func Show(running *Info) error { client := http.Client{Timeout: pingTimeout} - url := fmt.Sprintf("http://127.0.0.1:%d/rpc/%s?token=%s", running.Port, showWindowMethod, running.Token) + url := fmt.Sprintf("http://127.0.0.1:%d/rpc/%s?token=%s", running.Port, api.SystemShowWindow.Name, running.Token) resp, err := client.Post(url, "application/json", strings.NewReader(showWindowBody)) if err != nil { return fmt.Errorf("ask the running lich for its window: %w", err) diff --git a/internal/spawn/ask.go b/internal/spawn/ask.go index eeec28b53..fe12aac73 100644 --- a/internal/spawn/ask.go +++ b/internal/spawn/ask.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/terminal" ) @@ -17,21 +18,9 @@ const AskWaitLimit = 90 * time.Second // no conversation yet (docs/hooks/mod-control.md). const modNothingToFork = "nothing-to-fork" -// Answered is a session's answer to a side question. -type Answered struct { - ID string `json:"id"` - Project string `json:"project"` - Label string `json:"label"` - Answer string `json:"answer"` -} +type Answered = api.SessionAnswer -// AskOptions is one Ask call, an object for the reason CloseOptions is one. -type AskOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` - Question string `json:"question"` -} +type AskOptions = api.AskSessionOptions // Ask puts a side question to a running Claude Code session and waits for its // answer. The session's mod answers it with a tool-less fork of the session's diff --git a/internal/spawn/close.go b/internal/spawn/close.go index 5a017e5c0..4d016240e 100644 --- a/internal/spawn/close.go +++ b/internal/spawn/close.go @@ -5,8 +5,11 @@ import ( "log/slog" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" + + "github.com/lichdotdev/lich/internal/events" ) // What a caller may do with the checkout a session was the last one in. The @@ -20,37 +23,9 @@ const ( RemoveWorktree = "remove" ) -// Closed is what a caller is told about the session it closed. -type Closed struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` - Project string `json:"project"` - Label string `json:"label"` - // Worktree is the checkout the session lived in, empty for a session in the - // project's own directory. - Worktree string `json:"worktree"` - // Kept is the answer given to the worktree question: the checkout was left on - // disk rather than removed. Meaningless unless the close had that question to - // answer — a session sharing its checkout, or living in the project's own - // directory, reports false while leaving everything where it was. - // - // Nothing here reports the park, because every close but a removal parks and - // a field that is true on all of them tells a caller nothing. - Kept bool `json:"kept"` - // Removed is whether the checkout was deleted. - Removed bool `json:"removed"` -} +type Closed = api.ClosedSession -// CloseOptions is one Close call, an object for the reason OpenOptions is one: -// an option a newer lich adds is dropped by an older backend, and one an older -// lich does not send is its zero value. -type CloseOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` - Worktree string `json:"worktree"` - Force bool `json:"force"` -} +type CloseOptions = api.CloseSessionOptions // Close closes a session, and settles what happens to the checkout it was the // last one in. @@ -238,7 +213,7 @@ func (s *Service) finish(found located, active, closerID string) { slog.Warn("spawn: close the session's terminal", "session", found.session.ID, "err", err) } if s.events != nil { - s.events.Emit(ClosedEventName, ClosedEvent{ + events.Send(s.events.Emit, api.SessionClosedEvent, ClosedEvent{ ID: found.session.ID, ProjectID: found.project.ID, ActiveID: active, }) } diff --git a/internal/spawn/close_test.go b/internal/spawn/close_test.go index 25c7fdc3f..652d18b67 100644 --- a/internal/spawn/close_test.go +++ b/internal/spawn/close_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" @@ -87,7 +88,7 @@ func TestCloseTakesDownASessionWithNothingAtStake(t *testing.T) { if len(term.closed) == 0 { t.Error("left the PTY running for a session that is gone from the workspace") } - if len(events.events) != 1 || events.events[0].name != ClosedEventName { + if len(events.events) != 1 || events.events[0].name != api.SessionClosedEvent.Name { t.Errorf("events = %+v, want the card taken down", events.events) } } @@ -364,7 +365,7 @@ func TestCloseTakesTheCardDownEvenWhenThePTYRefuses(t *testing.T) { if len(sessions.parked) != 1 { t.Fatalf("parked = %+v, want the row taken out of the workspace", sessions.parked) } - if len(events.events) != 1 || events.events[0].name != ClosedEventName { + if len(events.events) != 1 || events.events[0].name != api.SessionClosedEvent.Name { t.Errorf("events = %+v, want the card taken down anyway", events.events) } } diff --git a/internal/spawn/control.go b/internal/spawn/control.go index c72ee25aa..c313da02f 100644 --- a/internal/spawn/control.go +++ b/internal/spawn/control.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/terminal" ) @@ -31,29 +32,9 @@ const ( ControlEnded = terminal.ModEnded ) -// Controlled is what a caller is told about a command it gave a session. Value -// is the prompt, model, effort or slash command's name the action carried, and -// CommandID the id the session's mod acks it under. -type Controlled struct { - ID string `json:"id"` - Project string `json:"project"` - Label string `json:"label"` - Action string `json:"action"` - Value string `json:"value,omitempty"` - CommandID string `json:"command_id"` - State string `json:"state"` -} +type Controlled = api.ControlledSession -// ControlOptions is one Control call, an object for the reason CloseOptions -// is one. -type ControlOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` - Action string `json:"action"` - Value string `json:"value"` - Args string `json:"args"` -} +type ControlOptions = api.ControlSessionOptions // Control gives a running Claude Code session one command through its mod // (docs/hooks/mod-control.md) and waits a bounded time for the mod to confirm diff --git a/internal/spawn/folders.go b/internal/spawn/folders.go index 7424ff808..16c8ad4f8 100644 --- a/internal/spawn/folders.go +++ b/internal/spawn/folders.go @@ -6,75 +6,29 @@ import ( "slices" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" -) -// FiledEventName carries sessions whose folder changed outside the window, so -// their cards move into or out of the folder's block without a reload. One -// event for a card filed and for a folder renamed, because the window's answer -// to both is the same: set these cards' folder. -const FiledEventName = "sessions-filed" + "github.com/lichdotdev/lich/internal/events" +) -// FiledEvent is FiledEventName's payload. Folder is "" when the cards were taken -// out of the one they were in. -type FiledEvent struct { - ProjectID string `json:"projectId"` - IDs []string `json:"ids"` - Folder string `json:"folder"` -} +type FiledEvent = api.SessionsFiled -// ColoredEventName carries sessions painted outside the window, so their cards -// take the colour without a reload. -const ColoredEventName = "sessions-colored" - -// ColoredEvent is ColoredEventName's payload. Color is "" when the cards went -// back to the theme. -type ColoredEvent struct { - ProjectID string `json:"projectId"` - IDs []string `json:"ids"` - Color string `json:"color"` -} +type ColoredEvent = api.SessionsColored // cardColors are the names a card can be painted with, in the order the // window's menu offers them: cardColorNames in native/cmd/lich-native/cardcolor.go, // whose window owns the values and draws any other name as no colour at all. var cardColors = []string{"red", "orange", "amber", "green", "teal", "blue", "violet", "pink"} -// Folder is one of a project's folders as a caller outside the window sees it: -// its name, which is its whole identity (store.SetSessionFolder), and the -// sessions filed under it, by label. -type Folder struct { - Name string `json:"name"` - Sessions []string `json:"sessions"` -} +type Folder = api.SessionFolder -// Filed is what a caller is told about the session it filed. Previous is the -// folder it left, "" when it was in none. -type Filed struct { - ID string `json:"id"` - Project string `json:"project"` - Label string `json:"label"` - Folder string `json:"folder"` - Previous string `json:"previous"` -} +type Filed = api.FiledSession -// Refiled is what a caller is told about a folder it renamed or took apart: -// every session that moved, which is the part a merge into an existing folder -// would otherwise hide. -type Refiled struct { - Project string `json:"project"` - From string `json:"from"` - To string `json:"to"` - Sessions []string `json:"sessions"` -} +type Refiled = api.RenamedFolder -// FoldersOptions is one Folders call, an object for the reason CloseOptions is -// one. -type FoldersOptions struct { - From string `json:"from"` - Project string `json:"project"` -} +type FoldersOptions = api.FoldersOptions // Folders lists a project's folders. Project names it; empty takes the caller's // own, exactly as Worktrees does. @@ -112,13 +66,7 @@ func foldersOf(p store.Project) []Folder { return folders } -// FileOptions is one File call, an object for the reason CloseOptions is one. -type FileOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` - Folder string `json:"folder"` -} +type FileOptions = api.FileSessionOptions // File files a session under a folder, the window's "Move to folder" from // outside it; an empty folder takes the session out of the one it is in. A @@ -144,7 +92,7 @@ func (s *Service) File(opts FileOptions) (Filed, error) { return Filed{}, err } if s.events != nil { - s.events.Emit(FiledEventName, FiledEvent{ + events.Send(s.events.Emit, api.SessionsFiledEvent, FiledEvent{ ProjectID: found.project.ID, IDs: []string{found.session.ID}, Folder: folder, }) } @@ -157,14 +105,7 @@ func (s *Service) File(opts FileOptions) (Filed, error) { }, nil } -// RenameFolderOptions is one RenameFolder call, an object for the reason -// CloseOptions is one. Folder is the name to change and To the new one. -type RenameFolderOptions struct { - From string `json:"from"` - Project string `json:"project"` - Folder string `json:"folder"` - To string `json:"to"` -} +type RenameFolderOptions = api.RenameFolderOptions // RenameFolder renames one of a project's folders, or takes it apart when To is // empty: the window's "Rename folder" and "Ungroup" from outside it. Project @@ -196,28 +137,14 @@ func (s *Service) RenameFolder(opts RenameFolderOptions) (Refiled, error) { return Refiled{}, err } if s.events != nil { - s.events.Emit(FiledEventName, FiledEvent{ProjectID: target.ID, IDs: moved, Folder: to}) + events.Send(s.events.Emit, api.SessionsFiledEvent, FiledEvent{ProjectID: target.ID, IDs: moved, Folder: to}) } return Refiled{Project: target.Name, From: from, To: to, Sessions: labelsOf(target, moved)}, nil } -// Colored is what a caller is told about a folder it painted: the sessions that -// took the colour, by label. -type Colored struct { - Project string `json:"project"` - Folder string `json:"folder"` - Color string `json:"color"` - Sessions []string `json:"sessions"` -} +type Colored = api.ColoredFolder -// ColorFolderOptions is one ColorFolder call, an object for the reason -// CloseOptions is one. -type ColorFolderOptions struct { - From string `json:"from"` - Project string `json:"project"` - Folder string `json:"folder"` - Color string `json:"color"` -} +type ColorFolderOptions = api.ColorFolderOptions // ColorFolder paints every session filed under one of a project's folders, the // window's folder "Color" from outside it; an empty colour hands them back to @@ -250,7 +177,7 @@ func (s *Service) ColorFolder(opts ColorFolderOptions) (Colored, error) { return Colored{}, err } if s.events != nil { - s.events.Emit(ColoredEventName, ColoredEvent{ProjectID: target.ID, IDs: painted, Color: color}) + events.Send(s.events.Emit, api.SessionsColoredEvent, ColoredEvent{ProjectID: target.ID, IDs: painted, Color: color}) } return Colored{Project: target.Name, Folder: folder, Color: color, Sessions: labelsOf(target, painted)}, nil } diff --git a/internal/spawn/folders_test.go b/internal/spawn/folders_test.go index 473f8aa6b..96cea6ae4 100644 --- a/internal/spawn/folders_test.go +++ b/internal/spawn/folders_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) @@ -35,8 +36,8 @@ func filer(t *testing.T) (*Service, *fakeSessions, *fakeEvents) { // filedEvent reads the one sessions-filed announcement a call made. func filedEvent(t *testing.T, events *fakeEvents) FiledEvent { t.Helper() - if len(events.events) != 1 || events.events[0].name != FiledEventName { - t.Fatalf("events = %+v, want one %s", events.events, FiledEventName) + if len(events.events) != 1 || events.events[0].name != api.SessionsFiledEvent.Name { + t.Fatalf("events = %+v, want one %s", events.events, api.SessionsFiledEvent.Name) } return events.events[0].data.(FiledEvent) } @@ -358,8 +359,8 @@ func TestColorFolderPaintsEverySessionInIt(t *testing.T) { if !slices.Equal(colored.Sessions, []string{"shared-a", "alone"}) || colored.Color != "teal" { t.Errorf("colored = %+v, want every painted card named", colored) } - if len(events.events) != 1 || events.events[0].name != ColoredEventName { - t.Fatalf("events = %+v, want one %s", events.events, ColoredEventName) + if len(events.events) != 1 || events.events[0].name != api.SessionsColoredEvent.Name { + t.Fatalf("events = %+v, want one %s", events.events, api.SessionsColoredEvent.Name) } got := events.events[0].data.(ColoredEvent) if got.ProjectID != "p1" || !slices.Equal(got.IDs, []string{"s2", "s4"}) || got.Color != "teal" { diff --git a/internal/spawn/projects.go b/internal/spawn/projects.go index 66bf2217f..cf18385ec 100644 --- a/internal/spawn/projects.go +++ b/internal/spawn/projects.go @@ -5,8 +5,11 @@ import ( "path/filepath" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/store" + + "github.com/lichdotdev/lich/internal/events" ) // ensureProject resolves the project a session is being opened in, and is the @@ -76,7 +79,7 @@ func (s *Service) adoptProject(identity *project.Project) ([]store.Project, stor // in that order: a card is dropped when its project is not on screen // yet, so the tab has to land first. if s.events != nil { - s.events.Emit(ProjectOpenedEventName, p) + events.Send(s.events.Emit, api.ProjectOpenedEvent, p) } return reloaded, p, nil } diff --git a/internal/spawn/projects_test.go b/internal/spawn/projects_test.go index d9fde10ba..25d729179 100644 --- a/internal/spawn/projects_test.go +++ b/internal/spawn/projects_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/store" ) @@ -52,7 +53,7 @@ func TestOpenAdoptsADirectoryNotOnScreen(t *testing.T) { // The tab has to land before the card: a session-opened whose project is // not on screen yet is dropped by the window (adoptSession). if names := emittedNames(events); len(names) != 2 || - names[0] != ProjectOpenedEventName || names[1] != OpenedEventName { + names[0] != api.ProjectOpenedEvent.Name || names[1] != api.SessionOpenedEvent.Name { t.Fatalf("emitted %v, want the project before the session", names) } announced, ok := events.events[0].data.(store.Project) @@ -109,7 +110,7 @@ func TestOpenFindsAnOpenProjectByPath(t *testing.T) { if opened.ProjectID != "p2" { t.Errorf("opened in %q, want the project already holding that directory", opened.ProjectID) } - if names := emittedNames(events); len(names) != 1 || names[0] != OpenedEventName { + if names := emittedNames(events); len(names) != 1 || names[0] != api.SessionOpenedEvent.Name { t.Errorf("emitted %v, want only the session", names) } } diff --git a/internal/spawn/rename.go b/internal/spawn/rename.go index d7cf85d44..73aa7764d 100644 --- a/internal/spawn/rename.go +++ b/internal/spawn/rename.go @@ -5,43 +5,15 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) -// RenamedEventName carries a session's new name, so every card takes it without -// a reload. The store emits it once store.RenameSession has written the row, -// whoever asked: the window, `lich rename` or the MCP tool. -// -// It is the event the auto-applied ai-title already emits -// (terminal.titleEventName) rather than one of its own: the payload is the same -// pair and the window's answer to both is the same — relabel that card. A second -// name for one instruction would have bought a second handler saying it again. -const RenamedEventName = "session-title" +type RenamedEvent = api.SessionTitle -// RenamedEvent is RenamedEventName's payload, the shape terminal's title event -// writes. -type RenamedEvent struct { - ID string `json:"id"` - Label string `json:"label"` -} +type Renamed = api.RenamedSession -// Renamed is what a caller is told about the session it renamed. Previous is -// the name that is gone, which is the half the caller cannot look up afterwards. -type Renamed struct { - ID string `json:"id"` - Project string `json:"project"` - Label string `json:"label"` - Previous string `json:"previous"` -} - -// RenameOptions is one Rename call, an object for the reason CloseOptions is -// one. -type RenameOptions struct { - From string `json:"from"` - Target string `json:"target"` - Project string `json:"project"` - Label string `json:"label"` -} +type RenameOptions = api.RenameSessionOptions // Rename gives a session the name on its card, the window's rename from outside // the window. Like the window's, it makes the name the user's: the provider's diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go index 6724ac760..5219e537e 100644 --- a/internal/spawn/spawn.go +++ b/internal/spawn/spawn.go @@ -29,38 +29,17 @@ import ( "strings" "sync" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" "github.com/lichdotdev/lich/internal/terminal" -) - -// OpenedEventName carries a session opened outside the window, so the card -// appears without a reload. Global, like every other session event: its consumer -// is the workspace state, which outlives any one card. -const OpenedEventName = "session-opened" -// ProjectOpenedEventName carries a project opened outside the window — an agent -// naming a directory rather than a project already on screen — so its tab -// appears without a reload. Its payload is the whole store.Project, sessions -// included: a reopened project brings back the cards it was closed with, and the -// session this open is about to announce lands in a tab the window has to be -// holding already. -const ProjectOpenedEventName = "project-opened" + "github.com/lichdotdev/lich/internal/events" +) -// ClosedEventName carries a session closed outside the window, so the card goes -// away without a reload. Its payload is ClosedEvent. -const ClosedEventName = "session-closed" - -// ClosedEvent is the payload of ClosedEventName: which session left, the project -// it left, and which of that project's sessions is active now — the window has -// no say in the choice, because the row that records it is already written. -type ClosedEvent struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` - ActiveID string `json:"activeId"` -} +type ClosedEvent = api.SessionClosed // The size a session opened here starts at: none of its own, which the terminal // service reads as "whatever size the window last reported" (terminal.sizeFor). @@ -137,37 +116,7 @@ type Events interface { Emit(name string, data any) } -// Session is one opened session: what the caller is told, and what the window is -// handed to draw the card. Both read the same struct because both need the same -// facts — a divergence between them is a card that does not match the session -// the agent was given. -// -// Name is the second name the session answers to, empty for a Claude Code one, -// which names itself (relay.RosterNameOf); Label is the name on its card. Both -// address it (see internal/relay). Path is the worktree -// the session lives in, empty for a session in the project's own directory — -// the store's own spelling. NextSeq is the project's label counter after this -// session took its number. OriginSessionID and OriginLabel name the session that -// asked for this one — empty when the caller was not a session at all. -// Confined is whether the session opened inside the sandbox. Nobody is at this -// end to be asked, so it is the rung's own answer (store.SandboxDefault), and -// saying it is what keeps a caller from discovering the empty home and the -// read-only machine by running into them. Folder is the folder it was filed -// under, "" for none. -type Session struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` - Project string `json:"project"` - Label string `json:"label"` - Name string `json:"name"` - Kind string `json:"kind"` - Path string `json:"path"` - NextSeq int `json:"nextSeq"` - OriginSessionID string `json:"originSessionId"` - OriginLabel string `json:"originLabel"` - Confined bool `json:"confined"` - Folder string `json:"folder"` -} +type Session = api.OpenedSession // Service opens sessions on behalf of a caller outside the window. type Service struct { @@ -186,49 +135,7 @@ func New(sessions Sessions, worktrees Worktrees, term Terminal, events Events) * return &Service{sessions: sessions, worktrees: worktrees, term: term, events: events} } -// OpenOptions is one Open call. It travels as a single JSON object rather than -// as positional arguments because a package manager replaces the lich binary -// under a running backend: a newer CLI's option the backend does not know is -// dropped, and one an older CLI does not send is its zero value, where a -// positional list refuses both on its count. -// -// From is the session the caller runs in, empty for the command line run -// outside one. It decides two defaults: the project the session lands in and the -// provider it runs, both taken from the caller unless named. A caller with no -// session of its own must name the project — there is nothing to inherit. It is -// also recorded on the row as the new session's origin, which outlives the -// request that created it: the card says where it came from for as long as it -// exists. -// -// Project is a project already on screen, by name, or a directory path — and -// a path is the only way to reach a project the window is not holding, which is -// what opening one from the command line means. See ensureProject. -// -// Worktree, when given, is the branch name of a new git worktree created off -// Base (the project's current branch when Base is empty); the session is rooted -// there and labelled after it, exactly as the window's own worktree flow does. A -// Base without a Worktree has nothing to branch, and is refused rather than ignored. -// -// Model, when given, is the model the provider is spawned on, in that provider's -// own spelling. It is recorded on the row so every later spawn repeats it. -// Effort, when given, is the reasoning effort, passed and recorded the same way. -// Ultracode turns Claude Code's ultracode on, at whatever effort the session -// runs; it is recorded the same way, and refused for every other provider. -// -// Folder, when given, is the folder the session is filed under from its first -// frame: it is written with the row, so the card arrives in the folder's block -// rather than among its checkout's cards and then jumps. -type OpenOptions struct { - From string `json:"from"` - Project string `json:"project"` - Kind string `json:"kind"` - Worktree string `json:"worktree"` - Base string `json:"base"` - Model string `json:"model"` - Effort string `json:"effort"` - Folder string `json:"folder"` - Ultracode bool `json:"ultracode"` -} +type OpenOptions = api.OpenSessionOptions // Open creates a session and starts its PTY. func (s *Service) Open(opts OpenOptions) (Session, error) { @@ -239,18 +146,7 @@ func (s *Service) Open(opts OpenOptions) (Session, error) { }) } -// OpenSubagentOptions is one OpenSubagent call, an object for the reason -// OpenOptions is one. Its fields mean what Open's do; there is no project or -// folder, both of which are the caller's. -type OpenSubagentOptions struct { - From string `json:"from"` - Kind string `json:"kind"` - Worktree string `json:"worktree"` - Base string `json:"base"` - Model string `json:"model"` - Effort string `json:"effort"` - Ultracode bool `json:"ultracode"` -} +type OpenSubagentOptions = api.OpenSubagentOptions // OpenSubagent opens a session as the subagent of the session From runs in // (`lich open --subagent`), the way lich-plugin's mod turns Claude Code's own @@ -372,7 +268,7 @@ func (s *Service) openSession(req request) (Session, error) { // either way, so the card has to exist either way too — a session only the // database knows about is one the user cannot reach to see what went wrong. if s.events != nil { - s.events.Emit(OpenedEventName, opened) + events.Send(s.events.Emit, api.SessionOpenedEvent, opened) } callerErr := s.fileCaller(target.ID, callerToFile, folder) markErr := s.markSubagent(id, label, req) @@ -427,7 +323,7 @@ func (s *Service) fileCaller(projectID, callerID, folder string) error { return fmt.Errorf("the subagent is open, but its caller could not be filed under %q: %w", folder, err) } if s.events != nil { - s.events.Emit(FiledEventName, FiledEvent{ProjectID: projectID, IDs: []string{callerID}, Folder: folder}) + events.Send(s.events.Emit, api.SessionsFiledEvent, FiledEvent{ProjectID: projectID, IDs: []string{callerID}, Folder: folder}) } return nil } diff --git a/internal/spawn/spawn_test.go b/internal/spawn/spawn_test.go index a8f6b5ea2..a2b83dde8 100644 --- a/internal/spawn/spawn_test.go +++ b/internal/spawn/spawn_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/project" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" @@ -461,8 +462,8 @@ func TestOpenLandsInTheCallersProject(t *testing.T) { if want := relay.RosterName("/src/lich", opened.ID); opened.Name != want { t.Errorf("reported the name %q, want %q, the one the relay resolves", opened.Name, want) } - if len(events.events) != 1 || events.events[0].name != OpenedEventName { - t.Fatalf("events = %+v, want one %s", events.events, OpenedEventName) + if len(events.events) != 1 || events.events[0].name != api.SessionOpenedEvent.Name { + t.Fatalf("events = %+v, want one %s", events.events, api.SessionOpenedEvent.Name) } if events.events[0].data != any(opened) { t.Errorf("the window was told %+v, the caller %+v", events.events[0].data, opened) @@ -578,7 +579,7 @@ func TestOpenAnnouncesTheCardEvenWhenTheModelCannotBeRecorded(t *testing.T) { if len(sessions.rows) != 1 { t.Fatalf("wrote %d rows, want the session's own", len(sessions.rows)) } - if len(events.events) != 1 || events.events[0].name != OpenedEventName { + if len(events.events) != 1 || events.events[0].name != api.SessionOpenedEvent.Name { t.Errorf("events = %+v, want the card announced for the row that exists", events.events) } if len(term.spawns) != 1 { @@ -599,7 +600,7 @@ type modelAtAnnounce struct { } func (m *modelAtAnnounce) Emit(name string, data any) { - if opened, ok := data.(Session); ok && name == OpenedEventName { + if opened, ok := data.(Session); ok && name == api.SessionOpenedEvent.Name { m.seen[opened.ID] = m.sessions.models[opened.ID] } } diff --git a/internal/spawn/subagent_test.go b/internal/spawn/subagent_test.go index 8d502d8f8..a4a22a18e 100644 --- a/internal/spawn/subagent_test.go +++ b/internal/spawn/subagent_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) @@ -90,7 +91,7 @@ func TestOpenSubagentFilesTheCallerBesideItsWorkers(t *testing.T) { } last := events.events[len(events.events)-1] filed, ok := last.data.(FiledEvent) - if last.name != FiledEventName || !ok || filed.Folder != "Session 3" || + if last.name != api.SessionsFiledEvent.Name || !ok || filed.Folder != "Session 3" || len(filed.IDs) != 1 || filed.IDs[0] != "s1" || filed.ProjectID != "p1" { t.Errorf("last event %+v, want the caller announced into its folder", last) } @@ -113,7 +114,7 @@ func TestOpenSubagentFilesTheWorkerUnderTheCallersFolder(t *testing.T) { t.Errorf("caller was refiled: %v", sessions.folders) } for _, e := range events.events { - if e.name == FiledEventName { + if e.name == api.SessionsFiledEvent.Name { t.Errorf("caller announced as moved: %+v", e) } } @@ -222,7 +223,7 @@ func TestCloseFinishedWorkerClosesOneSharingItsCallersCheckout(t *testing.T) { if len(term.closed) != 1 || term.closed[0] != id { t.Errorf("%s: closed PTYs = %v, want the worker's", id, term.closed) } - if len(events.events) != 1 || events.events[0].name != ClosedEventName { + if len(events.events) != 1 || events.events[0].name != api.SessionClosedEvent.Name { t.Errorf("%s: events = %+v, want its card taken down", id, events.events) } } diff --git a/internal/spawn/worktrees.go b/internal/spawn/worktrees.go index 67074f7ac..31a2ec0b3 100644 --- a/internal/spawn/worktrees.go +++ b/internal/spawn/worktrees.go @@ -4,29 +4,13 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) -// Checkout is one of a project's git worktrees, as a caller outside the window -// needs to see it: what it is called, where it is, whether anything in it is -// uncommitted, and which sessions are open in it. -// -// Sessions is what makes the list actionable rather than informational. A -// checkout with none is one nobody is working in; the last session in one is the -// thing whose closing decides whether the checkout stays (see Close). -type Checkout struct { - Name string `json:"name"` - Path string `json:"path"` - Dirty bool `json:"dirty"` - Sessions []string `json:"sessions"` -} +type Checkout = api.WorktreeCheckout -// WorktreesOptions is one Worktrees call, an object for the reason CloseOptions -// is one. -type WorktreesOptions struct { - From string `json:"from"` - Project string `json:"project"` -} +type WorktreesOptions = api.WorktreesOptions // Worktrees lists a project's checkouts. Project names it; empty takes the // caller's own, exactly as Open does. diff --git a/internal/store/cost.go b/internal/store/cost.go index c254ff080..1e05fa7e0 100644 --- a/internal/store/cost.go +++ b/internal/store/cost.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -217,58 +218,9 @@ func (s *Service) SessionCost(sessionID string) (float64, error) { // providers.CostSource's answers. const CostSourceMixed = "mixed" -// CostTotal is what one project's sessions have cost. Sessions is every session -// in the unit; Unpriced is how many of them carry no price at all, so the money -// covers Sessions-Unpriced of them. -// -// Source is whose arithmetic that money is: providers.CostSourcePriced when -// lich derived every dollar of it from token counts, providers.CostSourceReported -// when the providers that spent it handed their own figures over, and -// CostSourceMixed when the row spans both. Empty is a row with nothing to -// attribute — every session in it unpriced, or its spend earned by a provider -// CLI run by hand inside a shell session, which lich never spawned a rung for. -type CostTotal struct { - Project string `json:"project"` - Sessions int `json:"sessions"` - Unpriced int `json:"unpriced"` - Source string `json:"source"` - CostUSD float64 `json:"costUsd"` -} +type CostTotal = api.CostTotal -// CostReport is CostTotals' answer: a row per project, and the same numbers -// summed across every row. -// -// The money is a **lower bound** whenever Unpriced is not zero. A session lich -// never priced holds no ledger row at all — its provider files its conversation -// where lich has no reader, a model in it has no price, or the readout was off -// while it ran — and nothing here can guess what it spent. Shipping that count -// beside the sum -// is the whole difference between "this is what it cost" and "this is what lich -// could add up", and a total that omitted it in silence would be the one -// failure mode that makes the number not worth reading. -// -// Why the row is missing is not in here on purpose: that is costMiss -// (internal/terminal/usage_cost.go), which is decided by the live scan and -// never persisted, so the ledger can only say that a session has no price — -// not which of the reasons it was. -// -// Priced and Reported are how many counted sessions each rung contributed, and -// they are what lets a reader weigh the total: the sum is the one place the -// difference between the two is invisible, since both arrive as dollars. -// -// Readout is whether the cost readout is on at all. Off, no transcript is ever -// summed, so every session is unpriced and the total can only be zero — which a -// caller has to be able to say instead of printing a $0.00 that reads as free. -type CostReport struct { - Projects []CostTotal `json:"projects"` - Sessions int `json:"sessions"` - Unpriced int `json:"unpriced"` - Priced int `json:"priced"` - Reported int `json:"reported"` - Source string `json:"source"` - CostUSD float64 `json:"costUsd"` - Readout bool `json:"readout"` -} +type CostReport = api.CostReport // projectRow accumulates one project's groups while the query is walked. The // two tallies are session counts per rung, kept out of CostTotal because the @@ -313,16 +265,7 @@ func costSource(priced, reported int) string { return string(providers.CostSourceNone) } -// CostTotalsOptions is one CostTotals call. It travels as a single JSON object -// rather than as positional arguments because a package manager replaces the -// lich binary under a running backend: a filter a newer `lich cost` adds is -// dropped by an older backend, and one an older `lich cost` does not send is no -// filter, where a positional list refuses both on its count. -type CostTotalsOptions struct { - Project string `json:"project"` - Provider string `json:"provider"` - Since int64 `json:"since"` -} +type CostTotalsOptions = api.CostTotalsOptions // CostTotals sums the ledger by project. project narrows it to one by name, // case-insensitively; provider to one session kind; either empty means all of diff --git a/internal/store/cost_totals_test.go b/internal/store/cost_totals_test.go index 178a36e4e..116044e74 100644 --- a/internal/store/cost_totals_test.go +++ b/internal/store/cost_totals_test.go @@ -6,8 +6,11 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/rpc" + + "context" ) // costWorkspace opens a store with two projects and the sessions named, so a @@ -450,7 +453,9 @@ func TestCostTotalsAcrossVersions(t *testing.T) { bill(t, svc, "p1", "s1", "claude", 1.25) bill(t, svc, "p2", "s2", "codex", 0.75) dispatcher := rpc.New() - dispatcher.Register("store", svc) + rpc.Handle(dispatcher, api.StoreCostTotals, func(_ context.Context, o api.CostTotalsOptions) (api.CostReport, error) { + return svc.CostTotals(o) + }) recorder := httptest.NewRecorder() body := `[{"provider":"codex","optionFromTheFuture":true}]` diff --git a/internal/store/external.go b/internal/store/external.go index 503af78b6..33e7f4dd0 100644 --- a/internal/store/external.go +++ b/internal/store/external.go @@ -7,6 +7,7 @@ import ( "path/filepath" "slices" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -26,23 +27,7 @@ type Conversation struct { Profile string } -// ExternalSession is a conversation started outside lich that one of its -// projects can adopt: it ran in that project's directory or in one of its -// checkouts. Path is what the adopted row carries, "" for the project's own -// directory, the same as a session lich opened there. -type ExternalSession struct { - Kind string `json:"kind"` - ProviderSessionID string `json:"providerSessionId"` - Title string `json:"title"` - Path string `json:"path"` - ProjectID string `json:"projectId"` - ProjectName string `json:"projectName"` - ProjectPath string `json:"projectPath"` - UpdatedAt int64 `json:"updatedAt"` - // Profile is the Claude Code or Codex profile the conversation lives in, - // the one a resume of it runs in whatever the project's own profile is. - Profile string `json:"profile,omitempty"` -} +type ExternalSession = api.ExternalSession // SetConversationsOf registers how to list the conversations on disk // (terminal.Conversations). Startup wiring; without it there are no external diff --git a/internal/store/history.go b/internal/store/history.go index d18a01d02..7a63499df 100644 --- a/internal/store/history.go +++ b/internal/store/history.go @@ -3,63 +3,13 @@ package store import ( "fmt" "strings" + + "github.com/lichdotdev/lich/api" ) -// ClosedSession is one parked session offered for resuming — what identifies it -// in a list somebody is browsing rather than one they are already looking at. -// The project rides along because history spans every project at once, closed -// ones included, and the project name is what tells two sessions of the same -// name apart. -// -// Two answers about the branch, and they are different questions: ParkedBranch -// is the snapshot this row was closed on, which is what the search matches, and -// what the window draws is read live off the checkout, because a worktree's -// directory stops agreeing with its branch the moment an agent branches inside -// it (native/cmd/lich-native/model.go, checkoutLabel). A row whose checkout is gone has -// nothing live to show — which is the same row that cannot be resumed anyway. -type ClosedSession struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` - ProjectName string `json:"projectName"` - // The project's own directory, so resuming a session of a closed project can - // reopen that project first without a second lookup — and can ask where it - // went when the directory has moved. - ProjectPath string `json:"projectPath"` - Label string `json:"label"` - Kind string `json:"kind"` - Path string `json:"path"` - // The branch the checkout was on at the close, empty for a row parked before - // lich recorded one. Searchable, not drawn. - ParkedBranch string `json:"parkedBranch"` - // Unix seconds, 0 for a row parked before closed_at existed — which sorts - // last and is drawn as no date rather than as 1970. - ClosedAt int64 `json:"closedAt"` - // Whether the search reached this row through its conversation. It is what - // the window keeps the row on, apart from the snippet: a hit the index found - // by a fold the plain text does not have ("cion" against "ción"), or a term - // the index split on punctuation, has a snippet of nothing to show. - MatchedConversation bool `json:"matchedConversation"` - // The stretch of conversation that matched the search, empty for a row the - // search matched by name, for one matched by a fold (see above), and for - // every row of an empty query, which matched nothing in particular. - Snippet string `json:"snippet"` - // Whether the cap on one session's index dropped the oldest of this - // conversation. The row says so on screen: a search that reaches only part - // of a session has to be readable as that rather than as an absence. - Truncated bool `json:"truncated"` -} +type ClosedSession = api.ParkedSession -// ClosedHistory is one page of the parked-session history and the size of the -// match it was cut from. Total is what lets the list say it was cut instead of -// presenting closedSessionLimit rows as the whole answer. -type ClosedHistory struct { - Sessions []ClosedSession `json:"sessions"` - Total int `json:"total"` - // Parked sessions whose conversation has not been indexed yet, which is what - // the list reports while the backfill catches up on a workspace that predates - // the index. 0 once every row has one, which is the steady state. - Indexing int `json:"indexing"` -} +type ClosedHistory = api.ClosedHistory // closedSessionLimit caps the history handed to the window — how many rows one // call answers with, not how far back it looks: the search runs in the query, diff --git a/internal/store/mutations.go b/internal/store/mutations.go index 09c318768..b33f59943 100644 --- a/internal/store/mutations.go +++ b/internal/store/mutations.go @@ -7,7 +7,10 @@ import ( "log/slog" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" + + "github.com/lichdotdev/lich/internal/events" ) // now is time.Now, replaced in tests: a close stamps the row with it, and a @@ -49,7 +52,7 @@ func (s *Service) AddProject(id, name, path string) error { if err != nil { return fmt.Errorf("add project %q: %w", id, err) } - s.projectHasChanged(ProjectAddedEventName, ProjectAdded{ID: id, Name: name, Path: path}) + events.Send(s.projectHasChanged, api.ProjectAddedEvent, ProjectAdded{ID: id, Name: name, Path: path}) return nil } @@ -69,7 +72,7 @@ func (s *Service) CloseProject(id string) error { return fmt.Errorf("close project %q: %w", id, err) } if n, err := res.RowsAffected(); err == nil && n > 0 { - s.projectHasChanged(ProjectClosedEventName, ProjectClosed{ID: id}) + events.Send(s.projectHasChanged, api.ProjectClosedEvent, ProjectClosed{ID: id}) } return nil } @@ -975,7 +978,7 @@ func (s *Service) ReorderProjects(ids []string) error { if err != nil { return err } - s.projectHasChanged(ProjectsReorderedEventName, ProjectsReordered{IDs: ids}) + events.Send(s.projectHasChanged, api.ProjectsReorderedEvent, ProjectsReordered{IDs: ids}) return nil } diff --git a/internal/store/projectevents.go b/internal/store/projectevents.go index 35b53fee3..3d50ee52b 100644 --- a/internal/store/projectevents.go +++ b/internal/store/projectevents.go @@ -1,39 +1,12 @@ package store -// ProjectAddedEventName is emitted once AddProject has committed: a project -// opened new, reopened from the history, or reopened at the directory it moved -// to — the same id under another path is how a relocate reaches the store. -// Payload: ProjectAdded. It is not project-opened, which an agent's open -// announces with the whole project and which the web workspace adds a tab -// from; this one only says the row changed, and the window reads it back. -const ProjectAddedEventName = "project-added" +import "github.com/lichdotdev/lich/api" -// ProjectClosedEventName is emitted once CloseProject has marked a project -// closed, whoever asked: the window's own tab, an agent, the CLI. -// Payload: ProjectClosed. -const ProjectClosedEventName = "project-closed" +type ProjectAdded = api.ProjectAdded -// ProjectsReorderedEventName is emitted once ReorderProjects has written the -// tab order. Payload: ProjectsReordered. -const ProjectsReorderedEventName = "projects-reordered" +type ProjectClosed = api.ProjectClosed -// ProjectAdded is the payload of ProjectAddedEventName: the identity as stored. -type ProjectAdded struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` -} - -// ProjectClosed is the payload of ProjectClosedEventName. -type ProjectClosed struct { - ID string `json:"id"` -} - -// ProjectsReordered is the payload of ProjectsReorderedEventName: every open -// project, in the order that was stored. -type ProjectsReordered struct { - IDs []string `json:"ids"` -} +type ProjectsReordered = api.ProjectsReordered // SetProjectChanged registers what to run for each change to the project list // once it is committed. The three events above are what it is told. diff --git a/internal/store/projectevents_test.go b/internal/store/projectevents_test.go index a9cfe8d8c..8d2076dcd 100644 --- a/internal/store/projectevents_test.go +++ b/internal/store/projectevents_test.go @@ -3,6 +3,8 @@ package store import ( "reflect" "testing" + + "github.com/lichdotdev/lich/api" ) type projectEvent struct { @@ -54,9 +56,9 @@ func TestAddProjectEventCarriesTheStoredIdentity(t *testing.T) { _ = svc.AddProject("p1", "moved", "/tmp/moved") want := []projectEvent{ - {ProjectAddedEventName, ProjectAdded{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}}, - {ProjectClosedEventName, ProjectClosed{ID: "p1"}}, - {ProjectAddedEventName, ProjectAdded{ID: "p1", Name: "moved", Path: "/tmp/moved"}}, + {api.ProjectAddedEvent.Name, ProjectAdded{ID: "p1", Name: "alpha", Path: "/tmp/alpha"}}, + {api.ProjectClosedEvent.Name, ProjectClosed{ID: "p1"}}, + {api.ProjectAddedEvent.Name, ProjectAdded{ID: "p1", Name: "moved", Path: "/tmp/moved"}}, } if !reflect.DeepEqual(*got, want) { t.Errorf("events = %+v, want %+v", *got, want) @@ -84,7 +86,7 @@ func TestReorderProjectsAnnouncesTheStoredOrder(t *testing.T) { if err := svc.ReorderProjects([]string{"p2", "p1"}); err != nil { t.Fatalf("ReorderProjects: %v", err) } - want := []projectEvent{{ProjectsReorderedEventName, ProjectsReordered{IDs: []string{"p2", "p1"}}}} + want := []projectEvent{{api.ProjectsReorderedEvent.Name, ProjectsReordered{IDs: []string{"p2", "p1"}}}} if !reflect.DeepEqual(*got, want) { t.Errorf("events = %+v, want %+v", *got, want) } diff --git a/internal/store/store.go b/internal/store/store.go index 63b40c249..f2f61b0ea 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -24,6 +24,8 @@ import ( "time" _ "modernc.org/sqlite" + + "github.com/lichdotdev/lich/api" ) // schema is the version-1 shape, applied once by the first migration @@ -286,11 +288,6 @@ func (s *Service) sessionIsGone(sessionIDs ...string) { } } -// ScheduleForfeitEventName is emitted when a scheduled prompt was lost with the -// session it was parked on, so the person who parked it is told rather than left -// to find out at a time that never comes. Payload: ForfeitedSchedule. -const ScheduleForfeitEventName = "session-schedule-forfeited" - // ForfeitedSchedule is one scheduled prompt that went with a deleted session: // what that session was called, when the prompt was due, and the prompt itself. // It carries no session id — the row is gone, so there is nothing left to route @@ -349,85 +346,9 @@ func (s *Service) SetBranchOf(fn func(path string) string) { s.branchOf = fn } -// Session is a persisted terminal session (metadata only). Kind selects what -// the PTY runs: a provider id (see internal/providers) or "shell" (the user's -// shell). Path is the session's working directory when it lives in a git -// worktree; empty means the project's own path. ProviderSessionID is the id the -// provider CLI assigns the conversation running in the PTY, reported by that -// provider's session-start hook; empty until a hook fires (or for shell -// sessions), it is the key for features that need to reach a session's -// transcript or resume it. Pinned keeps a -// session at the head of its project's list and withholds its close affordances -// until it is unpinned. OriginSessionID and OriginLabel record the session that -// asked for this one, empty for a session nobody delegated: the id names the -// parent while it is still in the workspace, the label is what that parent was -// called at the time and is all that is left once it is closed. -type Session struct { - ID string `json:"id"` - Label string `json:"label"` - Kind string `json:"kind"` - Path string `json:"path"` - ProviderSessionID string `json:"providerSessionId"` - // Sandbox is whether this session runs confined: "on", "off", or empty for a - // row nothing has spawned yet. The spawn writes its own verdict here, so the - // window can mark a confined card without re-deriving a decision that took - // the provider's rung, the checkout and a per-session override to reach. - Sandbox string `json:"sandbox"` - Pinned bool `json:"pinned"` - // Folder is the sidebar block this session was filed under, "" for one the - // user has not filed. It rides the row rather than a preference because it - // is what the list is organised by — a workspace of forty sessions that - // came back ungrouped after a restart would be a workspace nobody files. - Folder string `json:"folder"` - // Color is the palette colour the user painted the card with, "" for one - // that follows the theme. - Color string `json:"color"` - // Profile is the Claude Code profile the session runs in - // (internal/claudeprofile), "" for Personal and for every other provider. - Profile string `json:"profile"` - // Model is the model the session was born on (SetSessionModel), "" for one - // opened on its provider's default. - Model string `json:"model"` - OriginSessionID string `json:"originSessionId"` - OriginLabel string `json:"originLabel"` - // Forked is whether the session was born branching its origin's - // conversation, rather than delegated or opened as a subagent. - Forked bool `json:"forked"` - // ScheduledAt is when the prompt below is due, in unix seconds, 0 for a - // session with nothing waiting. It rides the session row so the card that - // draws the countdown and the relay that delivers it read the same hydration - // call, and neither needs a channel of its own. - ScheduledAt int64 `json:"scheduledAt"` - ScheduledPrompt string `json:"scheduledPrompt"` - // Unread is whether this session's last finished turn is still waiting to be - // read. It rides the hydration call because the mark has to survive a window - // restart: the window holds it while it is up, and this row is what it comes - // back to (see SetSessionUnread). - Unread bool `json:"unread"` - // MCPServers names the MCP servers this session's provider could reach when - // it was spawned. It rides the row for the reason Sandbox does: the spawn is - // what resolved it — from the provider's own config documents and this - // session's directory — and a window restart has to come back to the same - // answer without re-deriving it. Nil for a row nothing has spawned yet. - MCPServers []string `json:"mcpServers"` - // SandboxSkippedLinks names the paths under the user's home this session's - // sandbox left out for being symlinks, relative to that home. It rides the - // row for the reason MCPServers does — the spawn is what resolved it, and a - // window restart has to come back to the same answer. Nil for an unconfined - // session and for one whose sandbox skipped nothing. - SandboxSkippedLinks []string `json:"sandboxSkippedLinks"` -} +type Session = api.Session -// Project is a persisted project together with its restorable session state. -type Project struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - NextSeq int `json:"nextSeq"` - ActiveSessionID string `json:"activeSessionId"` - DefaultProvider string `json:"defaultProvider"` - Sessions []Session `json:"sessions"` -} +type Project = api.Project // New opens (creating if absent) the SQLite database under the user's config // directory and applies the schema. @@ -561,13 +482,7 @@ func (s *Service) loadProjectDefaults(projects []Project) { } } -// Recent is a closed project offered for reopening — identity only, since the -// menu that lists them shows a name and a path and nothing else. -type Recent struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` -} +type Recent = api.RecentProject // recentLimit caps how many closed projects one call answers with, not how far // back it looks: the search runs in the query, so a project closed long before diff --git a/internal/system/native.go b/internal/system/native.go index 3df43e9e0..d7363f817 100644 --- a/internal/system/native.go +++ b/internal/system/native.go @@ -4,6 +4,8 @@ import ( "fmt" "log/slog" "sync" + + "github.com/lichdotdev/lich/api" ) // NativeHooks are how lich drives its window, the native one (`lich native`), @@ -39,12 +41,7 @@ func (s *Service) SetNativeWindow(hooks NativeHooks) { s.native.mu.Unlock() } -// AttachNativeOptions is the native window announcing itself. -type AttachNativeOptions struct { - // Executable is the native window's own binary, the one started again when - // lich is asked for its window after that window closed. - Executable string `json:"executable"` -} +type AttachNativeOptions = api.AttachNativeOptions // AttachNative records the native window that started as lich's window. func (s *Service) AttachNative(opts AttachNativeOptions) { diff --git a/internal/system/system.go b/internal/system/system.go index 6b1af58c1..3cbab5415 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -17,6 +17,7 @@ import ( "sync" "sync/atomic" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relpath" "github.com/lichdotdev/lich/internal/sandbox" "github.com/ncruces/zenity" @@ -84,13 +85,7 @@ func (s *Service) TakeUncleanExit() bool { return s.uncleanExit.Swap(false) } -// Diagnostics is what a bug report opens with: the three facts that decide -// whether a report is actionable, and the log path the user attaches. -type Diagnostics struct { - Version string `json:"version"` - Platform string `json:"platform"` - LogPath string `json:"logPath"` -} +type Diagnostics = api.Diagnostics func (s *Service) Diagnostics() Diagnostics { return Diagnostics{ diff --git a/internal/system/window.go b/internal/system/window.go index f2e16fad8..311270705 100644 --- a/internal/system/window.go +++ b/internal/system/window.go @@ -1,5 +1,7 @@ package system +import "github.com/lichdotdev/lich/api" + // The backend outlives its window: closing the window leaves every session // running, and these two calls are how anyone else gets the window back or // ends lich for good. A second launch of lich asks for ShowWindow, and @@ -10,10 +12,7 @@ func (s *Service) SetQuit(quit func() error) { s.quit = quit } -// ShowWindowOptions is one ShowWindow call. It carries nothing yet; it is an -// object so a newer launch can still reach an older running lich the day it -// does (the CLI's options-object rule). -type ShowWindowOptions struct{} +type ShowWindowOptions = api.ShowWindowOptions // ShowWindow starts the native window on the running backend, or brings it // forward when it is open. It returns as soon as the window is asked for, not @@ -24,8 +23,7 @@ func (s *Service) ShowWindow(ShowWindowOptions) error { return nil } -// QuitOptions is one Quit call, an object for the reason ShowWindowOptions is. -type QuitOptions struct{} +type QuitOptions = api.QuitOptions // Quit ends lich: the window closes, every session's process ends with it, and // the process exits. It returns once the exit is under way, so the reply can be diff --git a/internal/terminal/account.go b/internal/terminal/account.go index 25227afbc..ffa2799ea 100644 --- a/internal/terminal/account.go +++ b/internal/terminal/account.go @@ -10,8 +10,8 @@ package terminal // (env_other.go), and a reading is withheld rather than taken against the // machine-wide login lich happens to see. // -// Denied to the window (denyInternal in main.go): the environment it returns -// carries that session's own credentials. +// Not routed over /rpc/ (internal/routes): the environment it returns carries +// that session's own credentials. func (s *Service) SessionAccount(id string) (env map[string]string, read bool) { if !envReadable { return nil, false diff --git a/internal/terminal/cwd.go b/internal/terminal/cwd.go index 054ad0302..a167cf185 100644 --- a/internal/terminal/cwd.go +++ b/internal/terminal/cwd.go @@ -6,22 +6,12 @@ import ( "strings" "sync" "time" -) -// cwdEventName carries a session's live working directory ({id, cwd, host}), -// emitted once with the directory the PTY starts in and again whenever the -// child process moves (the user runs `cd`). A non-empty host names a -// foreground process the directory cannot be read through (see shellHosts) and -// comes with an empty cwd: the session is somewhere, but not somewhere this -// machine can name. Global like the other session events. -const cwdEventName = "session-cwd" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) -// cwdEvent is the payload of cwdEventName. -type cwdEvent struct { - ID string `json:"id"` - Cwd string `json:"cwd"` - Host string `json:"host"` -} +type cwdEvent = api.SessionCwd // cwdBook is the last session-cwd payload published for each session, which // TurnStates hands a window that attached after it: /events replays nothing, @@ -59,7 +49,7 @@ func (b *cwdBook) forget(id string) { // the reason publishStatus is. func (s *Service) publishCwd(ev cwdEvent) { s.cwds.note(ev) - s.hub.Emit(cwdEventName, ev) + events.Send(s.hub.Emit, api.SessionCwdEvent, ev) } // cwdPollInterval is how often a session's child working directory is read. diff --git a/internal/terminal/duplicate_test.go b/internal/terminal/duplicate_test.go index 1bdb8eb86..f187eeaa9 100644 --- a/internal/terminal/duplicate_test.go +++ b/internal/terminal/duplicate_test.go @@ -8,6 +8,7 @@ package terminal import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -21,8 +22,8 @@ func TestASessionStartOnAHeldConversationTellsTheWindow(t *testing.T) { if err := svc.onSessionStart("resumer", "conv-shared", providers.Claude); err != nil { t.Fatalf("onSessionStart = %v, want nil", err) } - waitFor(t, func() bool { _, ok := rec.payloadOf(duplicateEventName); return ok }, "the duplicate event") - payload, _ := rec.payloadOf(duplicateEventName) + waitFor(t, func() bool { _, ok := rec.payloadOf(api.SessionDuplicateEvent.Name); return ok }, "the duplicate event") + payload, _ := rec.payloadOf(api.SessionDuplicateEvent.Name) got, _ := payload.(map[string]any) if got["id"] != "resumer" || got["heldBy"] != "holder" { t.Errorf("duplicate event = %v, want resumer held by holder", payload) @@ -39,8 +40,8 @@ func TestASessionStartOnItsOwnConversationRaisesNoWarning(t *testing.T) { } // The agent event is pushed after the check, on the same socket, so once // it is in a warning would have been too. - waitFor(t, func() bool { _, ok := rec.payloadOf(agentEventName); return ok }, "the agent event") - if payload, ok := rec.payloadOf(duplicateEventName); ok { + waitFor(t, func() bool { _, ok := rec.payloadOf(api.SessionAgentEvent.Name); return ok }, "the agent event") + if payload, ok := rec.payloadOf(api.SessionDuplicateEvent.Name); ok { t.Errorf("duplicate event = %v, want none for a conversation nobody else runs", payload) } } @@ -53,8 +54,8 @@ func TestASessionStartTellsTheWindowTheConversation(t *testing.T) { if err := svc.onSessionStart("s1", "conv-own", providers.Claude); err != nil { t.Fatalf("onSessionStart = %v, want nil", err) } - waitFor(t, func() bool { _, ok := rec.payloadOf(conversationEventName); return ok }, "the conversation event") - payload, _ := rec.payloadOf(conversationEventName) + waitFor(t, func() bool { _, ok := rec.payloadOf(api.SessionConversationEvent.Name); return ok }, "the conversation event") + payload, _ := rec.payloadOf(api.SessionConversationEvent.Name) got, _ := payload.(map[string]any) if got["id"] != "s1" || got["providerSessionId"] != "conv-own" { t.Errorf("conversation event = %v, want s1 running conv-own", payload) diff --git a/internal/terminal/io.go b/internal/terminal/io.go index 3e7aa605b..3e2d3f0be 100644 --- a/internal/terminal/io.go +++ b/internal/terminal/io.go @@ -4,6 +4,9 @@ import ( "encoding/base64" "math" "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" ) // readBufSize is the chunk size read from a session's PTY per iteration. @@ -84,7 +87,7 @@ func (s *Service) stream(id string, sess *session) { if exited != nil { exited(id) } - s.hub.Emit(exitEventPrefix+id, exitPayload(code)) + events.Send(s.hub.Emit, api.TerminalExitEvents.Of(id), exitPayload(code)) } } diff --git a/internal/terminal/limit.go b/internal/terminal/limit.go index 9666a20de..f5e27ad6c 100644 --- a/internal/terminal/limit.go +++ b/internal/terminal/limit.go @@ -7,13 +7,11 @@ import ( "slices" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" -) -// limitEventName carries a turn a plan's usage limit ended ({id, window, -// resetsAt}). Nothing clears it; the window reads plan quota again when it -// arrives (planQuota, native/cmd/lich-native/footer_quota.go). -const limitEventName = "session-limit" + "github.com/lichdotdev/lich/internal/events" +) // limitWatchTick is how often an open Codex turn is checked for having ended on // a usage limit. Codex raises no hook when that happens (docs/hooks/session-state.md), @@ -37,12 +35,7 @@ type usageLimit struct { resetsAt int64 } -// limitEvent is the payload of limitEventName. -type limitEvent struct { - ID string `json:"id"` - Window string `json:"window"` - ResetsAt int64 `json:"resetsAt"` -} +type limitEvent = api.SessionLimit // usageLimitFor reads whether a conversation's last turn ended on a usage limit. // Only Claude Code and Codex record one together with its reset; every other @@ -200,7 +193,7 @@ func (s *Service) noteLimit(id string) bool { if !ok { return false } - s.hub.Emit(limitEventName, limitEvent{ID: id, Window: limit.window, ResetsAt: limit.resetsAt}) + events.Send(s.hub.Emit, api.SessionLimitEvent, limitEvent{ID: id, Window: limit.window, ResetsAt: limit.resetsAt}) if park := s.limitWatcher(); park != nil && limit.resetsAt > 0 { park(id, limit.resetsAt) } diff --git a/internal/terminal/limit_service_test.go b/internal/terminal/limit_service_test.go index bb2c52b3d..9483e32bc 100644 --- a/internal/terminal/limit_service_test.go +++ b/internal/terminal/limit_service_test.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "testing" + + "github.com/lichdotdev/lich/api" ) // parkRecorder stands in for the relay's ParkResume. @@ -49,8 +51,8 @@ func TestADoneOnAClaudeLimitTellsTheWindowAndParksTheResume(t *testing.T) { if at, _ := parks.of("s1"); at != 1788653400 { t.Errorf("parked for %d, want the transcript's reset 1788653400", at) } - waitFor(t, func() bool { _, ok := rec.payloadOf(limitEventName); return ok }, "the limit event") - payload, _ := rec.payloadOf(limitEventName) + waitFor(t, func() bool { _, ok := rec.payloadOf(api.SessionLimitEvent.Name); return ok }, "the limit event") + payload, _ := rec.payloadOf(api.SessionLimitEvent.Name) got, _ := payload.(map[string]any) if got["id"] != "s1" || got["window"] != limitWindowSession || got["resetsAt"] != float64(1788653400) { t.Errorf("limit event = %v", payload) diff --git a/internal/terminal/pluginversion.go b/internal/terminal/pluginversion.go index 59e973cfe..f1f2f3c4d 100644 --- a/internal/terminal/pluginversion.go +++ b/internal/terminal/pluginversion.go @@ -4,6 +4,7 @@ import ( "log/slog" "sync" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/agentplugin" ) @@ -11,16 +12,7 @@ import ( // (docs/hooks/README.md, Shared transport). const pluginVersionHeader = "X-Lich-Plugin" -// pluginEventName carries a session whose hooks report from a plugin release -// outside the range this lich speaks ({id, version}). The window answers it by -// asking for the plugin status again, which is where the fix is offered: an -// install moved by the harness's own marketplace shows up here first. -const pluginEventName = "plugin-incompatible" - -type pluginEvent struct { - ID string `json:"id"` - Version string `json:"version"` -} +type pluginEvent = api.PluginIncompatible // hookBody is what every hook request carries: the session it reports on. type hookBody interface { diff --git a/internal/terminal/probehub_test.go b/internal/terminal/probehub_test.go index 2a0378f99..641f38420 100644 --- a/internal/terminal/probehub_test.go +++ b/internal/terminal/probehub_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/coder/websocket" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -44,7 +45,7 @@ func (r *eventRecorder) add(name string, data any) { r.payloads = make(map[string]any) } r.payloads[name] = data - if name == statusEventName { + if name == api.SessionStatusEvent.Name { r.states = append(r.states, decodeStatus(data)) } } diff --git a/internal/terminal/profile.go b/internal/terminal/profile.go index 9a4fd377f..7008d7b1f 100644 --- a/internal/terminal/profile.go +++ b/internal/terminal/profile.go @@ -6,22 +6,17 @@ import ( "log/slog" "path/filepath" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/claudeprofile" "github.com/lichdotdev/lich/internal/codexprofile" "github.com/lichdotdev/lich/internal/ompprofile" "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" -) -// profileEventName carries the profile a session moved to: Continue in, or a -// resume that found its conversation in another profile. -const profileEventName = "session-profile" + "github.com/lichdotdev/lich/internal/events" +) -type profileEvent struct { - ID string `json:"id"` - // Profile is the slug, "" for Personal. - Profile string `json:"profile"` -} +type profileEvent = api.SessionProfile // profileOps is what the service asks of one provider's profiles: the packages // that keep them (internal/claudeprofile, internal/codexprofile, @@ -144,7 +139,7 @@ func (s *Service) reportProfileMove(id, bornIn string) { if err != nil || now == bornIn { return } - s.hub.Emit(profileEventName, profileEvent{ID: id, Profile: now}) + events.Send(s.hub.Emit, api.SessionProfileEvent, profileEvent{ID: id, Profile: now}) } // profileSlug is the profile a state directory of kind's provider belongs to, @@ -253,7 +248,7 @@ func (s *Service) ContinueIn(id, profile string) error { err = s.respawnAfter(nil, live, id, started, kind, conversation) // After the respawn: a reader that re-reads on this event (the quota of // the session's account) must find the process already under the profile. - s.hub.Emit(profileEventName, profileEvent{ID: id, Profile: profile}) + events.Send(s.hub.Emit, api.SessionProfileEvent, profileEvent{ID: id, Profile: profile}) return err } diff --git a/internal/terminal/profile_unix_test.go b/internal/terminal/profile_unix_test.go index bbdeb1252..80a5e7278 100644 --- a/internal/terminal/profile_unix_test.go +++ b/internal/terminal/profile_unix_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/claudeprofile" "github.com/lichdotdev/lich/internal/codexprofile" "github.com/lichdotdev/lich/internal/events" @@ -254,8 +255,8 @@ func TestAResumeThatFollowsItsConversationSaysSoOnceUp(t *testing.T) { if !svc.Live("s1") { t.Fatal("the session is not running after Start") } - waitFor(t, func() bool { _, ok := rec.payloadOf(profileEventName); return ok }, "the session-profile event") - payload, _ := rec.payloadOf(profileEventName) + waitFor(t, func() bool { _, ok := rec.payloadOf(api.SessionProfileEvent.Name); return ok }, "the session-profile event") + payload, _ := rec.payloadOf(api.SessionProfileEvent.Name) if fields, _ := payload.(map[string]any); fields["id"] != "s1" || fields["profile"] != "work" { t.Errorf("session-profile = %v, want s1 moved to work", payload) } diff --git a/internal/terminal/respawn_test.go b/internal/terminal/respawn_test.go index 744093f51..55501c84e 100644 --- a/internal/terminal/respawn_test.go +++ b/internal/terminal/respawn_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -67,7 +68,7 @@ func TestRespawnedSessionIsNotToldItExited(t *testing.T) { deadline := time.Now().Add(reapSettle) for time.Now().Before(deadline) { - if slices.Contains(rec.snapshot(), exitEventPrefix+"s1") { + if slices.Contains(rec.snapshot(), api.TerminalExitEvents.Prefix+"s1") { t.Fatal("the dead PTY's reap emitted an exit event for the respawned session") } time.Sleep(time.Millisecond) @@ -113,7 +114,7 @@ func TestExitEventCarriesTheExitCode(t *testing.T) { t.Fatalf("start: %v", err) } - name := exitEventPrefix + "s1" + name := api.TerminalExitEvents.Prefix + "s1" waitFor(t, func() bool { _, ok := rec.payloadOf(name) return ok @@ -148,7 +149,7 @@ func TestExitEventOmitsAnUnobservedStatus(t *testing.T) { t.Fatalf("kill child: %v", err) } - name := exitEventPrefix + "s1" + name := api.TerminalExitEvents.Prefix + "s1" waitFor(t, func() bool { _, ok := rec.payloadOf(name) return ok diff --git a/internal/terminal/said.go b/internal/terminal/said.go index 3ed0a7c5e..21ff6ddef 100644 --- a/internal/terminal/said.go +++ b/internal/terminal/said.go @@ -9,6 +9,7 @@ import ( "slices" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -18,15 +19,7 @@ import ( // the start — the first paragraph is the recap. const saidRuneCap = 4000 -// LastSaid is the prose the agent ended its last turn with, the body of the -// window's finished-turn notification. Text is empty for every absence there is -// — a provider whose conversation lich cannot read, a turn that ended in a tool -// call, a transcript still being written — because the notification goes out -// without a body either way and a reason it cannot act on is a reason not worth -// wiring. -type LastSaid struct { - Text string `json:"text,omitempty"` -} +type LastSaid = api.LastSaid // The two sides of a conversation, spelled the way Claude Code's transcript // spells them. Every other provider's spelling is mapped onto these by its own diff --git a/internal/terminal/sandbox_report_test.go b/internal/terminal/sandbox_report_test.go index 8339db736..897b434f8 100644 --- a/internal/terminal/sandbox_report_test.go +++ b/internal/terminal/sandbox_report_test.go @@ -9,6 +9,7 @@ import ( "errors" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/providers" ) @@ -30,7 +31,7 @@ func TestReportSandboxRecordsAndAnnouncesTheSkippedLinks(t *testing.T) { t.Errorf("recorded %v, want %v", got, want) } waitFor(t, func() bool { - payload, ok := rec.payloadOf(sandboxEventName) + payload, ok := rec.payloadOf(api.SessionSandboxEvent.Name) if !ok { return false } @@ -52,7 +53,7 @@ func TestReportSandboxSurvivesARowThatRefusesTheWrite(t *testing.T) { svc.reportSandbox("s1", true, []string{".gitconfig"}) waitFor(t, func() bool { - _, ok := rec.payloadOf(sandboxEventName) + _, ok := rec.payloadOf(api.SessionSandboxEvent.Name) return ok }, "the sandbox event despite the failed write") } diff --git a/internal/terminal/search.go b/internal/terminal/search.go index db5a202ed..cb75d7114 100644 --- a/internal/terminal/search.go +++ b/internal/terminal/search.go @@ -6,6 +6,7 @@ import ( "strings" "unicode/utf8" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/snippet" ) @@ -23,14 +24,7 @@ const minSearchQuery = 3 // Indexing is the fix if that ever bites — not a bigger number. const searchTailBytes = 4 << 20 -// TranscriptMatch is one session whose conversation mentions the query: the -// session id to jump to, the newest matching message trimmed to a line, and how -// many of its messages matched. -type TranscriptMatch struct { - ID string `json:"id"` - Snippet string `json:"snippet"` - Count int `json:"count"` -} +type TranscriptMatch = api.TranscriptMatch // SearchTranscripts finds which of the given sessions have talked about query, // and what they said. The caller passes the sessions it can route to (the diff --git a/internal/terminal/start.go b/internal/terminal/start.go index 3cfbf798a..6e3c5f9fa 100644 --- a/internal/terminal/start.go +++ b/internal/terminal/start.go @@ -6,9 +6,12 @@ import ( "os" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/agentplugin" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/relay" + + "github.com/lichdotdev/lich/internal/events" ) // Start spawns the binary for session id under project projectID — the user's @@ -90,7 +93,7 @@ func (s *Service) Start( // would freeze every session's I/O. Both are unconditional so a respawn // overwrites whatever the previous PTY left in the window's stores. s.publishCwd(cwdEvent{ID: id, Cwd: cwd}) - s.hub.Emit(agentEventName, agentEvent{ID: id, Agent: ""}) + events.Send(s.hub.Emit, api.SessionAgentEvent, agentEvent{ID: id, Agent: ""}) s.reportProfileMove(id, bornIn) s.reportSandbox(id, sess.confined, sess.sandboxLinks) s.reportMCPServers(id, kind, cwd) @@ -272,7 +275,7 @@ func (s *Service) reportSandbox(id string, confined bool, links []string) { if err := s.store.SetSessionSandboxLinks(id, links); err != nil { slog.Warn("record sandbox skipped links", "session", id, "error", err) } - s.hub.Emit(sandboxEventName, sandboxEvent{ID: id, Confined: confined, SkippedLinks: links}) + events.Send(s.hub.Emit, api.SessionSandboxEvent, sandboxEvent{ID: id, Confined: confined, SkippedLinks: links}) } // reportMCPServers records and announces the MCP servers this spawn's provider @@ -289,5 +292,5 @@ func (s *Service) reportMCPServers(id, kind, cwd string) { if err := s.store.SetSessionMCPServers(id, servers); err != nil { slog.Warn("record MCP servers", "session", id, "error", err) } - s.hub.Emit(mcpEventName, mcpEvent{ID: id, Servers: servers}) + events.Send(s.hub.Emit, api.SessionMCPEvent, mcpEvent{ID: id, Servers: servers}) } diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 7f974eaca..398ac5e40 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/awake" "github.com/lichdotdev/lich/internal/events" "github.com/lichdotdev/lich/internal/pricing" @@ -22,109 +23,15 @@ import ( "github.com/lichdotdev/lich/internal/quota" ) -// Event names. A terminal I/O event carries the session ID as a suffix (e.g. -// "terminal:data:home") so each terminal subscribes only to its own -// stream instead of filtering a global broadcast. The session events below are -// global and carry the id in their payload: their consumers outlive any one -// card, and a per-session name can only reach a subscriber that exists when it -// is emitted. -const ( - // dataEventPrefix carries base64-encoded PTY output. Output is base64-encoded - // because raw PTY bytes may split a multi-byte UTF-8 sequence mid-read, which - // the JSON event bridge would otherwise corrupt. - dataEventPrefix = "terminal:data:" - // exitEventPrefix is emitted once when a session's shell process exits, - // carrying the status it exited with (see exitEvent). - exitEventPrefix = "terminal:exit:" - // statusEventName carries a session's processing state ({id, state, tool, - // detail, reason} — "busy"/"done"/"waiting"/"idle"/"compacting", plus the - // tool a pre-tool report names and what a waiting one is blocked on), - // reported by the lich hooks running inside the PTY (see transport.hook and - // docs/hooks/session-state.md). "interrupted" is the one value lich raises - // itself, for a turn the user stopped at the PTY. The window keeps it in a - // store keyed by id (statusStore, native/cmd/lich-native/status.go). - statusEventName = "session-status" - // titleEventName carries an auto-applied session label ({id, label}). - titleEventName = "session-title" - // touchedEventName carries the id of a session that likely changed files on - // disk, nudging an immediate git-status refresh ahead of the steady poll. - touchedEventName = "session-touched" - // agentEventName carries which provider CLI is live inside a session's PTY - // ({id, agent}) — reported through the session-start hook by whichever - // provider ships it, so a shell card shows that provider's mark while its - // CLI runs in it. An empty agent clears the mark; every PTY spawn emits that - // clear so a respawned session never wears a dead agent's icon. - agentEventName = "session-agent" - // conversationEventName carries the provider conversation a session's - // row now names ({id, providerSessionId}, "" once it names none), emitted - // after the row is written: the window offers that conversation back when - // the session's process exits. - conversationEventName = "session-conversation" - // sandboxEventName carries whether a session's PTY runs confined ({id, - // confined, skippedLinks}), emitted by every spawn. The card marks a confined - // session, and the answer is the spawn's own — it takes the provider's rung, - // the checkout and a per-session override to reach, so the window is told - // rather than asked to work it out again. skippedLinks is what that sandbox - // left out of the private home for being a symlink, which is the one absence - // a session has no other way of learning about. Both persisted with the row - // (store.Session's Sandbox and SandboxSkippedLinks), which is what a - // reopened window hydrates from. - sandboxEventName = "session-sandbox" - // mcpEventName carries the MCP servers a session's provider could reach at - // its spawn ({id, servers}), emitted by every spawn. The card divides a tool - // name against them — two harnesses spell an MCP tool with a single - // underscore between the server and the tool, which nothing in the string - // divides (docs/hooks/session-state.md). Persisted with the row too - // (store.Session's MCPServers), which is what a reopened window hydrates - // from: the PTY outlives the window, so there is no second spawn to report - // it again. - mcpEventName = "session-mcp" - // duplicateEventName carries a session that just reported a conversation - // another open card already runs ({id, heldBy}): a resume typed in the - // provider's own TUI, which lich cannot stop, only point out. Emitted once - // the report is stored, so the window can offer to switch to heldBy. - duplicateEventName = "session-duplicate" -) - -// duplicateEvent is the payload of duplicateEventName. -type duplicateEvent struct { - ID string `json:"id"` - HeldBy string `json:"heldBy"` -} +type duplicateEvent = api.SessionDuplicate -// statusEvent is the payload of statusEventName: the session whose processing -// state changed, the new state, and — on a pre-tool report alone — the tool it -// is about to run and what that tool acts on, or — on a waiting one alone — what -// it is blocked on. All three are the provider's own words, never translated -// here (docs/hooks/session-state.md tables them). -type statusEvent struct { - ID string `json:"id"` - State string `json:"state"` - Tool string `json:"tool,omitempty"` - Detail string `json:"detail,omitempty"` - Reason string `json:"reason,omitempty"` -} +type statusEvent = api.SessionStatus -// titleEvent is the payload of titleEventName: the session whose label changed -// and its new label. -type titleEvent struct { - ID string `json:"id"` - Label string `json:"label"` -} +type titleEvent = api.SessionTitle -// touchedEvent is the payload of touchedEventName: the session whose files -// likely changed. -type touchedEvent struct { - ID string `json:"id"` -} +type touchedEvent = api.SessionTouched -// exitEvent is the payload of exitEventPrefix: the status the session's PTY -// child exited with. Code is absent when the child left none — killed by a -// signal, or a wait that failed — so the window can say the session ended -// without reporting a clean exit nobody observed. -type exitEvent struct { - Code *int `json:"code,omitempty"` -} +type exitEvent = api.SessionExit // exitPayload turns what Wait reaped into that payload. Every negative code is // noExitStatus, not only the constant: a process cannot exit negative, so a @@ -136,17 +43,9 @@ func exitPayload(code int) exitEvent { return exitEvent{Code: &code} } -// agentEvent is the payload of agentEventName: the session and the provider -// CLI now live in its PTY ("" when none is). -type agentEvent struct { - ID string `json:"id"` - Agent string `json:"agent"` -} +type agentEvent = api.SessionAgent -type conversationEvent struct { - ID string `json:"id"` - ProviderSessionID string `json:"providerSessionId"` -} +type conversationEvent = api.SessionConversation // setConversation writes the conversation session id's row names, then tells // the window. @@ -154,26 +53,13 @@ func (s *Service) setConversation(id, providerSessionID string) error { if err := s.store.SetProviderSession(id, providerSessionID); err != nil { return err } - s.hub.Emit(conversationEventName, conversationEvent{ID: id, ProviderSessionID: providerSessionID}) + events.Send(s.hub.Emit, api.SessionConversationEvent, conversationEvent{ID: id, ProviderSessionID: providerSessionID}) return nil } -// sandboxEvent is the payload of sandboxEventName: the session, whether its PTY -// is confined, and the home paths that sandbox skipped for being symlinks, -// relative to the home (nil for an unconfined spawn and for one that skipped -// none). -type sandboxEvent struct { - ID string `json:"id"` - Confined bool `json:"confined"` - SkippedLinks []string `json:"skippedLinks"` -} +type sandboxEvent = api.SessionSandbox -// mcpEvent is the payload of mcpEventName: the session and the MCP servers its -// provider could reach when it was spawned. -type mcpEvent struct { - ID string `json:"id"` - Servers []string `json:"servers"` -} +type mcpEvent = api.SessionMCP // session is a single running PTY-backed shell. done closes when the session // is reaped (by stream or Close — whichever removes it from the map), stopping @@ -459,7 +345,7 @@ func New(store Store, env []string, hub *events.Hub) *Service { // path output takes when the socket cannot carry it is wired here. ws.setFallback(s.emitData) ws.plugins.setOnIncompatible(func(id, version string) { - s.hub.Emit(pluginEventName, pluginEvent{ID: id, Version: version}) + events.Send(s.hub.Emit, api.PluginIncompatibleEvent, pluginEvent{ID: id, Version: version}) }) ws.setModAborted(s.noteInterrupt) ws.setUsageReported(s.onUsageReport) @@ -608,7 +494,7 @@ func (s *Service) onSessionStart(sessionID, providerSessionID, provider string) // reports `claude` from inside a Cursor session and the card wore // Claude's mark one turn in. What a hook says is a claim; what lich // spawned is a fact. - s.hub.Emit(agentEventName, agentEvent{ID: sessionID, Agent: s.providerKind(sessionID, provider)}) + events.Send(s.hub.Emit, api.SessionAgentEvent, agentEvent{ID: sessionID, Agent: s.providerKind(sessionID, provider)}) return nil } @@ -622,7 +508,7 @@ func (s *Service) reportDuplicate(sessionID, providerSessionID string) { return } if holder != "" { - s.hub.Emit(duplicateEventName, duplicateEvent{ID: sessionID, HeldBy: holder}) + events.Send(s.hub.Emit, api.SessionDuplicateEvent, duplicateEvent{ID: sessionID, HeldBy: holder}) } } @@ -634,7 +520,7 @@ func (s *Service) onTitle(id, title string) error { return err } if applied { - s.hub.Emit(titleEventName, titleEvent{ID: id, Label: title}) + events.Send(s.hub.Emit, api.SessionTitleEvent, titleEvent{ID: id, Label: title}) } return nil } @@ -643,14 +529,14 @@ func (s *Service) onTitle(id, title string) error { // reports nothing else. func (s *Service) onTouched(id string) { s.beatHandsOn(id, 0) - s.hub.Emit(touchedEventName, touchedEvent{ID: id}) + events.Send(s.hub.Emit, api.SessionTouchedEvent, touchedEvent{ID: id}) } // emitData puts one session's output on the /events bridge — where terminal // output goes whenever the /ws transport cannot carry it, whether because no // client is connected or because the socket refused the frame. func (s *Service) emitData(id string, data []byte) { - s.hub.Emit(dataEventPrefix+id, base64.StdEncoding.EncodeToString(data)) + events.Send(s.hub.Emit, api.TerminalDataEvents.Of(id), base64.StdEncoding.EncodeToString(data)) } // Mount exposes an extra handler (the RPC dispatcher, the events push socket) diff --git a/internal/terminal/turnstates.go b/internal/terminal/turnstates.go index 3c9d28573..a1d00497e 100644 --- a/internal/terminal/turnstates.go +++ b/internal/terminal/turnstates.go @@ -4,6 +4,9 @@ import ( "slices" "sync" "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" ) // statusBook is the last session-status payload published for each session. @@ -56,22 +59,7 @@ func (b *statusBook) forget(id string) { b.mu.Unlock() } -// TurnState is what the window missed about one live session while it was not -// attached to /events: the last session-status report (State is empty for a -// session that never reported) and the readout the last session-usage event -// carried (nil when there is none to read). Since is when the session entered -// State, in Unix milliseconds. Cwd and Host are its last session-cwd report. -type TurnState struct { - ID string `json:"id"` - State string `json:"state,omitempty"` - Tool string `json:"tool,omitempty"` - Detail string `json:"detail,omitempty"` - Reason string `json:"reason,omitempty"` - Since int64 `json:"since,omitempty"` - Usage *usageEvent `json:"usage,omitempty"` - Cwd string `json:"cwd,omitempty"` - Host string `json:"host,omitempty"` -} +type TurnState = api.TurnState // TurnStates is the catch-up a window takes right after attaching to /events: // what each live session last reported, taken from the same reports the event @@ -111,7 +99,7 @@ func (s *Service) TurnStates() []TurnState { // window has already been sent. func (s *Service) publishStatus(ev statusEvent) { s.statuses.note(ev) - s.hub.Emit(statusEventName, ev) + events.Send(s.hub.Emit, api.SessionStatusEvent, ev) } // forgetTurn drops what a session's process left behind: the open turn, the diff --git a/internal/terminal/turntodo.go b/internal/terminal/turntodo.go index e687ce2f7..516810b2e 100644 --- a/internal/terminal/turntodo.go +++ b/internal/terminal/turntodo.go @@ -5,26 +5,13 @@ import ( "os" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" + + "github.com/lichdotdev/lich/internal/events" ) -// todoEventName carries how far a session got through the task list its agent -// wrote for itself ({id, done, total}), emitted whenever that pair changes. -// Global like the other session events. -// -// The pair is what the agent last wrote, not what is true: an agent that -// abandons a list without closing its items leaves the card reporting the -// count it was abandoned at, until it writes another one. -const todoEventName = "session-todo" - -// todoEvent is the payload of todoEventName. Both counts are the whole list the -// agent last wrote, so a card can decide for itself whether a list is worth -// drawing (a finished one is not, and neither is a list of one). -type todoEvent struct { - ID string `json:"id"` - Done int `json:"done"` - Total int `json:"total"` -} +type todoEvent = api.SessionTodo // todoList is one task list as the sidebar reads it: how many of its items are // finished, and how many there are. The items themselves stay in the terminal, @@ -236,6 +223,6 @@ func (s *Service) emitTodo(id string, src usageSource) { s.todoEmit.Lock() defer s.todoEmit.Unlock() if event, ok := s.sessionTodo(id, src); ok { - s.hub.Emit(todoEventName, event) + events.Send(s.hub.Emit, api.SessionTodoEvent, event) } } diff --git a/internal/terminal/usage.go b/internal/terminal/usage.go index 366955666..133db3699 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -4,44 +4,13 @@ import ( "log/slog" "path/filepath" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" -) -// usageEventName carries a session's context-window usage ({id, percent, tokens}), -// emitted after a turn ends. Global like the other session events. -const usageEventName = "session-usage" + "github.com/lichdotdev/lich/internal/events" +) -// usageEvent is the payload of usageEventName. Percent is the share of the -// context window the turn left occupied (0–100); Tokens is the provider's active -// context count behind it, Window the model's effective context window, Model -// the model id, and Effort the reasoning effort level ("" when the line records -// none). -// -// All five are zero for a provider that records what a turn spent but not the -// window it spent it against — oh-my-pi, opencode and Crush, the cost-only rung -// of docs/ceilings.md (each in docs/providers/). A zero Window is the window's signal to draw the cost -// alone rather than a ring around a window nobody reported. -// -// CostUSD is what the session has cost so far, and is omitted — not zeroed — -// whenever there is no number worth showing: the setting is off, or a model in -// the transcript has no known price. The readout is for API billing, so on a -// subscription the field never appears at all and the footer shows nothing. -// -// CostMiss says why, and only when the answer is "lich cannot price this -// conversation" — a costMiss.spoken() one. It stays empty for the readout being -// off, which is not a failure but the design above, and for a miss the next turn -// heals. Without it a session that cannot be priced and a session that is not -// being priced look identical, and an empty footer reads as zero spend. -type usageEvent struct { - ID string `json:"id"` - Percent int `json:"percent"` - Tokens int `json:"tokens"` - Window int `json:"window"` - Model string `json:"model"` - Effort string `json:"effort"` - CostUSD *float64 `json:"costUsd,omitempty"` - CostMiss string `json:"costMiss,omitempty"` -} +type usageEvent = api.SessionUsage // emitUsage reads the context-window usage of the provider conversation running // in session id and pushes it to the window. Called off the status hook on @@ -58,7 +27,7 @@ func (s *Service) emitUsage(id string) { // emitUsageFrom is emitUsage once the transcript behind it is known. func (s *Service) emitUsageFrom(id string, src usageSource) { if event, ok := s.usageFrom(id, src); ok { - s.hub.Emit(usageEventName, event) + events.Send(s.hub.Emit, api.SessionUsageEvent, event) } } diff --git a/internal/terminal/usage_test.go b/internal/terminal/usage_test.go index 7172fd487..5fab6a57b 100644 --- a/internal/terminal/usage_test.go +++ b/internal/terminal/usage_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -169,7 +170,7 @@ func TestADoneReadsTheTranscriptAgainOnceItsLastLineLands(t *testing.T) { hub, rec := newProbeHub(t) svc := New(stubBins{providerSession: "uuid-late"}, nil, hub) modelOf := func() string { - payload, _ := rec.payloadOf(usageEventName) + payload, _ := rec.payloadOf(api.SessionUsageEvent.Name) fields, _ := payload.(map[string]any) model, _ := fields["model"].(string) return model @@ -177,7 +178,7 @@ func TestADoneReadsTheTranscriptAgainOnceItsLastLineLands(t *testing.T) { svc.onHookState(hookRequest{SessionID: "s1", State: statusBusy}) svc.onHookState(hookRequest{SessionID: "s1", State: statusDone}) - reads := func() int { return strings.Count(strings.Join(rec.snapshot(), "\n"), usageEventName) } + reads := func() int { return strings.Count(strings.Join(rec.snapshot(), "\n"), api.SessionUsageEvent.Name) } waitFor(t, func() bool { return reads() == 2 }, "the reads the busy and the done took") if got := modelOf(); got != modelOpus { t.Fatalf("model as the turn ended = %q, want the previous line's %q", got, modelOpus) diff --git a/internal/themes/git.go b/internal/themes/git.go index 95e30049d..c723ea103 100644 --- a/internal/themes/git.go +++ b/internal/themes/git.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/gitutil" "github.com/lichdotdev/lich/internal/semver" "github.com/lichdotdev/lich/internal/winexec" @@ -33,18 +34,7 @@ const ( // remote is usually copied in. var scpLike = regexp.MustCompile(`^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:[^\s]+$`) -// GitInstallResult reports what installing a repository produced. Conflicts -// names the theme ids that already exist locally: found by the scan that runs -// before any write, nothing was installed at all; found by the write itself — a -// theme that appeared in between — that one id was left alone and Themes says -// what did go in. -type GitInstallResult struct { - Pack string `json:"pack"` - Version string `json:"version"` - Themes []Theme `json:"themes"` - Conflicts []string `json:"conflicts"` - UpToDate bool `json:"upToDate"` -} +type GitInstallResult = api.ThemeGitResult // pack is a validated theme repository: its manifest plus every theme the // clone carried, already stamped with their source. diff --git a/internal/themes/selection.go b/internal/themes/selection.go index ceabe2c56..fb35be8a5 100644 --- a/internal/themes/selection.go +++ b/internal/themes/selection.go @@ -4,6 +4,9 @@ import ( "fmt" "slices" "strconv" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" ) // The settings the windows read the selection from, global ones. A theme @@ -15,16 +18,7 @@ const ( SystemID = "system" ) -// SelectionEventName is pushed on /events with a Selection once a change to -// it is stored. -const SelectionEventName = "theme-selection" - -// Selection is the theme the user chose and whether the window draws that -// theme's pair while the OS asks for the pair's scheme. -type Selection struct { - Theme string `json:"theme"` - FollowSystem bool `json:"followSystem"` -} +type Selection = api.ThemeSelection // Settings is where the selection is stored: the store's global settings. type Settings interface { @@ -48,10 +42,7 @@ func (s *Service) Selection() (Selection, error) { return Selection{Theme: theme, FollowSystem: follow == "true"}, nil } -// SelectOptions names the theme Select stores. -type SelectOptions struct { - ID string `json:"id"` -} +type SelectOptions = api.SelectThemeOptions // Select stores opts.ID, System or a theme List returns, as the selection. func (s *Service) Select(opts SelectOptions) error { @@ -82,6 +73,6 @@ func (s *Service) store(key, value string) error { if err != nil { return err } - s.emit(SelectionEventName, selection) + events.Send(s.emit, api.ThemeSelectionEvent, selection) return nil } diff --git a/internal/themes/selection_test.go b/internal/themes/selection_test.go index e2903c2d6..467a52131 100644 --- a/internal/themes/selection_test.go +++ b/internal/themes/selection_test.go @@ -3,6 +3,8 @@ package themes import ( "strings" "testing" + + "github.com/lichdotdev/lich/api" ) type memorySettings map[string]string @@ -52,7 +54,7 @@ func TestSelectStoresAndReportsTheSelection(t *testing.T) { if got := settings["/appearance.theme"]; got != "forest" { t.Fatalf("appearance.theme = %q, want forest", got) } - want := emitted{SelectionEventName, Selection{Theme: "forest"}} + want := emitted{api.ThemeSelectionEvent.Name, Selection{Theme: "forest"}} if len(*events) != 1 || (*events)[0] != want { t.Fatalf("events = %+v, want [%+v]", *events, want) } @@ -89,7 +91,7 @@ func TestSetFollowSystemStoresAndReportsIt(t *testing.T) { if got := settings["/appearance.followSystem"]; got != "true" { t.Fatalf("appearance.followSystem = %q, want true", got) } - want := emitted{SelectionEventName, Selection{Theme: "dark", FollowSystem: true}} + want := emitted{api.ThemeSelectionEvent.Name, Selection{Theme: "dark", FollowSystem: true}} if last := (*events)[len(*events)-1]; last != want { t.Fatalf("last event = %+v, want %+v", last, want) } diff --git a/internal/themes/syntax.go b/internal/themes/syntax.go index 375021a8a..4d083f3bc 100644 --- a/internal/themes/syntax.go +++ b/internal/themes/syntax.go @@ -1,50 +1,19 @@ package themes import ( - "encoding/json" "fmt" "strings" "github.com/lichdotdev/godemirror/tags" + "github.com/lichdotdev/lich/api" ) -// SyntaxStyle is how the code under one highlighting tag is drawn: a -// CodeMirror HighlightStyle spec. A theme file writes it as a bare color or -// as an object; Color empty leaves the code its foreground color. -type SyntaxStyle struct { - Color string `json:"color,omitempty"` - Bold bool `json:"bold,omitempty"` - Italic bool `json:"italic,omitempty"` - Underline bool `json:"underline,omitempty"` - Strikethrough bool `json:"strikethrough,omitempty"` -} - -// Syntax is a theme's syntax highlighting, keyed by the tag each style is -// for, as @lezer/highlight prints it: "keyword", "function(variableName)". -type Syntax map[string]SyntaxStyle - -// UnmarshalJSON reads each entry as a bare color or a style object, naming -// the entry that is neither. -func (s *Syntax) UnmarshalJSON(data []byte) error { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return fmt.Errorf("syntax must be an object of tag styles: %w", err) - } - out := make(Syntax, len(raw)) - for tag, entry := range raw { - var style SyntaxStyle - if err := json.Unmarshal(entry, &style.Color); err != nil { - if err := json.Unmarshal(entry, &style); err != nil { - return fmt.Errorf("syntax.%s must be a color or a style object", tag) - } - } - out[tag] = style - } - *s = out - return nil -} +type ( + SyntaxStyle = api.SyntaxStyle + Syntax = api.Syntax +) -func (s SyntaxStyle) setsFontStyle() bool { +func setsFontStyle(s SyntaxStyle) bool { return s.Bold || s.Italic || s.Underline || s.Strikethrough } @@ -54,7 +23,7 @@ func validateSyntax(syntax Syntax) error { return fmt.Errorf("syntax: %w", err) } if strings.TrimSpace(style.Color) == "" { - if !style.setsFontStyle() { + if !setsFontStyle(style) { return fmt.Errorf("syntax.%s sets no color and no font style", tag) } continue diff --git a/internal/themes/themes.go b/internal/themes/themes.go index 9adda912e..bcd348a4e 100644 --- a/internal/themes/themes.go +++ b/internal/themes/themes.go @@ -14,6 +14,7 @@ import ( "strings" "unicode/utf8" + "github.com/lichdotdev/lich/api" themeassets "github.com/lichdotdev/lich/themes" ) @@ -59,48 +60,14 @@ var ( ) ) -// Theme describes every color value the window needs to paint the app and -// the terminal. -type Theme struct { - FormatVersion int `json:"formatVersion,omitempty"` - ID string `json:"id"` - Name string `json:"name"` - Scheme string `json:"scheme"` - Origin string `json:"origin"` - Source *Source `json:"source,omitempty"` - App map[string]string `json:"app"` - Terminal map[string]string `json:"terminal"` - // Syntax is optional: a theme without it is highlighted in the window's - // default palette for its scheme. - Syntax Syntax `json:"syntax,omitempty"` - // Pair is optional: the id of this theme's variant for the other scheme, - // which the window draws while the OS asks for that scheme and the user - // turned on following it. - Pair string `json:"pair,omitempty"` -} - -// BrokenTheme is a stored custom theme this lich cannot load. It is listed -// rather than dropped, so the user sees why and can remove it by id. -type BrokenTheme struct { - ID string `json:"id"` - Reason string `json:"reason"` -} - -// Source records the repository a theme was installed from and the manifest -// version it came in at, so lich can clone it again to look for a newer one. It -// is written by lich, never by a picked file: a standalone import has no -// source and stays unversioned. -type Source struct { - URL string `json:"url"` - Version string `json:"version"` -} - -// ImportResult either reports the installed theme or asks the UI to confirm -// replacing the existing custom theme with the same id. -type ImportResult struct { - Theme Theme `json:"theme"` - NeedsOverwrite bool `json:"needsOverwrite"` -} +// The wire shapes live in api, the one definition the window and the CLI +// read too. +type ( + Theme = api.Theme + BrokenTheme = api.BrokenTheme + Source = api.ThemeSource + ImportResult = api.ThemeImportResult +) // Service reads bundled themes plus user-imported themes. type Service struct { diff --git a/internal/xdgprofile/service.go b/internal/xdgprofile/service.go index ac00ea986..31cdc723b 100644 --- a/internal/xdgprofile/service.go +++ b/internal/xdgprofile/service.go @@ -10,9 +10,12 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/winexec" + + "github.com/lichdotdev/lich/internal/events" ) const ( @@ -56,15 +59,7 @@ func (s *Service) SetSessions(inUse func(slug string) int, forget func(slug stri s.inUse, s.forget = inUse, forget } -// Info is a profile as the Settings screen shows it. -type Info struct { - Profile - LoggedIn bool `json:"loggedIn"` - Email string `json:"email,omitempty"` - // StatusError is why the status call gave no answer, in its own words; - // LoggedIn is then false for want of one. - StatusError string `json:"statusError,omitempty"` -} +type Info = api.ProfileInfo // Profiles lists every profile with its login. The statuses are asked for at // once, one CLI each. @@ -104,7 +99,7 @@ func (s *Service) UpdateProfile(slug string, u profilestore.Update) (Profile, er if err != nil { return p, err } - s.emit(profilestore.ChangedEvent, profilestore.Changed{Provider: s.provider.ID, Profile: p}) + events.Send(s.emit, api.ProfileChangedEvent, profilestore.Changed{Provider: s.provider.ID, Profile: p}) return p, nil } diff --git a/internal/xdgprofile/update_linux_test.go b/internal/xdgprofile/update_linux_test.go index f8e765f0b..acbe3a8e0 100644 --- a/internal/xdgprofile/update_linux_test.go +++ b/internal/xdgprofile/update_linux_test.go @@ -3,6 +3,7 @@ package xdgprofile import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" ) @@ -11,7 +12,7 @@ func TestUpdateProfileReportsTheProfileOnceWritten(t *testing.T) { machine(t) var got []profilestore.Changed s := New(Kiro, fakeBins{}, func(name string, data any) { - if c, ok := data.(profilestore.Changed); ok && name == profilestore.ChangedEvent { + if c, ok := data.(profilestore.Changed); ok && name == api.ProfileChangedEvent.Name { got = append(got, c) } }) diff --git a/main.go b/main.go index 75ba0acee..9565500b7 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "syscall" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/agentplugin" "github.com/lichdotdev/lich/internal/appupdate" "github.com/lichdotdev/lich/internal/claudeprofile" @@ -29,6 +30,7 @@ import ( "github.com/lichdotdev/lich/internal/quota" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/restart" + "github.com/lichdotdev/lich/internal/routes" "github.com/lichdotdev/lich/internal/rpc" "github.com/lichdotdev/lich/internal/shellintegration" "github.com/lichdotdev/lich/internal/singleton" @@ -262,11 +264,11 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // the file it copies for a confined session is one a human chose in a dialog // (drop.Attach says why that matters). drops.SetPicker(proj.PickFile) - dispatcher.Register("terminal", term) - dispatcher.Register("drop", drops) - dispatcher.Register("project", proj) + routes.Terminal(dispatcher, term) + routes.Drop(dispatcher, drops) + routes.Project(dispatcher, proj) plugins := agentplugin.New(db) - dispatcher.Register("agentplugin", plugins) + routes.AgentPlugin(dispatcher, plugins) go plugins.RepairRegistrations() // A profile follows Personal's lich plugin: an install or update there // reaches every Claude Code and Codex profile, and a start brings in the @@ -286,18 +288,23 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub } return errors.Join(quota.ForgetClaudeLogin(dir), db.ForgetClaudeProfile(slug)) }) - dispatcher.Register("claudeprofile", profiles) + routes.Profiles(dispatcher, api.ClaudeProfiles, profiles) + routes.ProfileLogin(dispatcher, api.ClaudeProfileLogin, profiles) + routes.ProfilePluginCalls(dispatcher, api.ClaudeProfilePlugins, profiles) + routes.ClaudeLoginCode(dispatcher, profiles.LoginCode) // Codex profiles have plugin sets of their own too, and get the lich plugin // the same way. codexProfiles := codexprofile.New(db, hub.Emit) codexProfiles.SetLichPlugin(plugins.InstallInCodexProfile, agentplugin.PluginKey) codexProfiles.SetSessions(term.CodexProfileSessions, db.ForgetCodexProfile) - dispatcher.Register("codexprofile", codexProfiles) + routes.Profiles(dispatcher, api.CodexProfiles, codexProfiles) + routes.ProfileLogin(dispatcher, api.CodexProfileLogin, codexProfiles) + routes.ProfilePluginCalls(dispatcher, api.CodexProfilePlugins, codexProfiles) // oh-my-pi's login is a terminal card's, so its service runs no login of its // own; on /events it reports only a profile's update. ompProfiles := ompprofile.New(db, hub.Emit) ompProfiles.SetSessions(term.OMPProfileSessions, db.ForgetOMPProfile) - dispatcher.Register("ompprofile", ompProfiles) + routes.Profiles(dispatcher, api.OMPProfiles, ompProfiles) // opencode, Cursor CLI and Kiro CLI log in from a terminal card too, and have // profiles only where a variable moves their logins (xdgprofile.Providers). for _, provider := range xdgprofile.Providers() { @@ -306,11 +313,11 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub func(slug string) int { return term.XDGProfileSessions(provider.ID, slug) }, func(slug string) error { return db.ForgetXDGProfile(provider.ID, slug) }, ) - dispatcher.Register(provider.ID+"profile", profiles) + routes.Profiles(dispatcher, api.XDGProfiles[provider.ID], profiles) } - dispatcher.Register("appupdate", appupdate.New(version, coord.Install, hub.Emit)) - dispatcher.Register("patchnotes", patchnotes.New(version, changelog, changelogTranslations)) - dispatcher.Register("store", db) + routes.AppUpdate(dispatcher, appupdate.New(version, coord.Install, hub.Emit)) + routes.PatchNotes(dispatcher, patchnotes.New(version, changelog, changelogTranslations)) + routes.Store(dispatcher, db) // Read before run writes this run's runtime file over the previous // run's: a file still there is the only trace a bad exit leaves, and the // restored workspace looks exactly like one closed on purpose. @@ -325,7 +332,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub Locate: func() (string, error) { return restart.NativeExecutable(os.Getenv) }, Failed: windowFailed(configDir, term.LiveCount, coord.Quit), }) - dispatcher.Register("system", sys) + routes.System(dispatcher, sys) // 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, @@ -333,14 +340,14 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // window for — an agent closing a session while a restart reopens the window, where // an event with no client connected is dropped. db.SetScheduleForfeited(func(lost store.ForfeitedSchedule) { - hub.Emit(store.ScheduleForfeitEventName, lost) + events.Send(hub.Emit, api.ScheduleForfeitedEvent, api.ScheduleForfeited(lost)) summary, detail := lost.Notice() if err := sys.Notify(summary, detail); err != nil { slog.Warn("notify forfeited schedule", "session", lost.Label, "err", err) } }) db.SetSessionRenamed(func(id, label string) { - hub.Emit(spawn.RenamedEventName, spawn.RenamedEvent{ID: id, Label: label}) + events.Send(hub.Emit, api.SessionTitleEvent, spawn.RenamedEvent{ID: id, Label: label}) }) db.SetProjectChanged(hub.Emit) providerSvc := providers.New() @@ -363,7 +370,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // with one configured agent and nothing on PATH reads as bare, and every // implicit new session opens a terminal instead. providerSvc.SetConfiguredBin(func(id string) string { return db.ProviderBin(id, "") }) - dispatcher.Register("providers", providerSvc) + routes.Providers(dispatcher, providerSvc) // The quota reading is per session, not per machine: a session spawned from // a binary the user configured can spend another account entirely, and the // terminal is what knows the environment that binary set up. @@ -380,7 +387,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub return on }) term.SetRateLimitReports(plans.ReportClaude) - dispatcher.Register("quota", plans) + routes.Quota(dispatcher, plans) codexProfiles.SetAccount(func(slug string) (string, string) { plan, err := plans.CodexProfilePlan(slug) if err != nil || plan.Status != quota.StatusOK { @@ -426,20 +433,19 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // terminal watches its open turns for one. term.SetUsageLimit(rl.ParkResume) go term.RunLimitWatch() - dispatcher.Register("relay", rl) + routes.Relay(dispatcher, rl) // Its caller is not the window either: opening a session for an agent starts // the PTY here rather than waiting for someone to click the card. spawner := spawn.New(db, proj, term, hub) // A subagent worker in its caller's checkout closes once it reported and // its turn ended, the way a native subagent ends with its result. rl.SetWorkerFinished(spawner.CloseFinishedWorker) - dispatcher.Register("spawn", spawner) - dispatcher.Register("themes", themes.New(version, db, hub.Emit)) + routes.Spawn(dispatcher, spawner) + routes.Themes(dispatcher, themes.New(version, db, hub.Emit)) // The tray is what a lich with its window closed shows of itself; it comes // up once the window hands it its words (tray.SetLabels). trayIcon := tray.New(trayIcons(), func() { showWindow(sys, "from the tray") }, coord.Quit, term.LiveCount) - dispatcher.Register("tray", trayIcon) - denyInternal(dispatcher) + routes.Tray(dispatcher, trayIcon) term.Mount("/rpc/", dispatcher) term.Mount("/drop", http.HandlerFunc(drops.Upload)) term.Mount("/blob", http.HandlerFunc(proj.ServeBlob)) @@ -447,152 +453,6 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub return sys } -// denyInternal hides the methods that exist for Go callers inside this process -// but must never answer an RPC POST. Registration exposes every exported method -// of a service (internal/rpc), so each of these is one /rpc/ path away from the -// window: -// -// - store.Close ends the database the whole app is still using. -// - drop.Upload and drop.Save take the dropped file's bytes as the request -// body, so the upload is its own endpoint — the RPC envelope is a JSON -// argument array with a 1MB bound. -// - relay.Observe is the hooks' session-state stream, which arrives over -// /hook: forging a SessionEnd here closes another session's errands. -// - relay.WorkerAnswered is a worker's answer, which arrives over -// /mod/answer: called here it answers another session's errand. -// relay.WorkerUnanswered arrives the same way and ends one unanswered. -// - relay.SessionClosed is what the terminal tells the relay as it closes a -// session: called here it ends a running worker's errand, in silence when -// the closer named is the worker's caller. relay.SessionExited is what it -// tells the relay as it reaps a process that ended on its own: called here -// it erases a live session's reported state. -// - terminal.CloseBy is a close on behalf of a session, which `lich close` -// reaches through spawn.Close: called here with the worker's caller as the -// closer, it ends that caller's errand without telling it. The window -// closes with terminal.Close. -// - drop.Purge deletes every copy dropped into a session, by id: the window -// closes sessions through the store, which is what reports one gone. -// - drop.SetPicker is startup wiring like the ones below, and nilling it -// would leave the footer's attach button with no dialog to open. -// - terminal.SessionAccount hands back the environment of the process a -// session runs, credentials and all, so the quota reader can tell which -// account that session spends. -// - agentplugin.RepairRegistrations rewrites provider config files and -// shells out to their CLIs; it is a launch step, not something a window asks. -// - relay.RunSchedules is the scheduled-prompt loop, started once at launch -// and never returning: called over /rpc/ it holds that request open for the -// life of the process and starts a second loop racing the first for every -// due prompt. terminal.RunLimitWatch is the same kind of loop, for the -// Codex turns a usage limit ended. -// - relay.ParkResume parks a prompt that types itself at a session; the -// terminal calls it for a turn a usage limit ended, with the reset it read. -// - spawn.CloseFinishedWorker closes a session with none of spawn.Close's -// checks; the relay calls it for a worker that reported back. -// - relay.SetPlugins, relay.SetWorkerFinished, relay.SetPromptLanguage, drop.SetPromptLanguage, project.SetAccounts, project.SetProjects, -// system.SetQuit, system.SetNativeWindow, -// quota.SetSessions, store.SetSessionGone, store.SetScheduleForfeited, -// store.SetSessionRenamed, store.SetProjectChanged, -// store.SetBranchOf, store.SetTranscriptOf, store.SetConversationsOf, -// store.SetCheckoutsOf, terminal.SetDropDir, -// terminal.SetRateLimitReports, terminal.SetUsageLimit, terminal.SetWorkerAnswer, -// terminal.SetWorkerUnanswered, terminal.SetStartReports, terminal.SetPromptLanguage, terminal.SetSessionClosed and terminal.SetSessionExited are startup wiring. Called with [null] they silently -// nil what they wired (encoding/json leaves a func or pointer alone on -// null), and the write races the readers already serving — nilling -// SetProjects also disarms the guard that keeps two projects off the same -// directory, and SetDropDir points the sandbox's read-only bind wherever -// the caller likes. -// - store.SetSessionProfile moves a session to another Claude Code profile -// without moving its conversation, which only terminal.ContinueIn may do; -// store.ForgetClaudeProfile, store.ForgetCodexProfile, -// store.ForgetOMPProfile and store.ForgetXDGProfile are the delete's own -// cleanup. -// - agentplugin.SetEmit is startup wiring like the setters above, and -// agentplugin.SyncProfiles is the start's pass over the profiles: called -// over /rpc/ it would run a second one racing the first. -// - claudeprofile.SetLichPlugin, claudeprofile.SetSessions, -// codexprofile.SetLichPlugin, codexprofile.SetSessions, -// codexprofile.SetAccount and -// ompprofile.SetSessions and the SetSessions of opencodeprofile, -// cursorprofile and kiroprofile are startup wiring like the setters above. -// - quota.ReportClaude is a session's own reading of its plan, which arrives -// over /mod/usage: called here it would put any numbers on the gauge. -// - terminal.EnqueueModCommand and terminal.RunModCommand queue a command for -// a session's mod with none of spawn.Control's checks (Claude Code only, -// never the caller's own session); `lich control` and control_session -// reach them through spawn.Control, `lich ask` and ask_session through -// spawn.Ask. -// - terminal.SubmitPrompt queues a prompt for a session's mod with none of the -// relay's ticket accounting; the relay is its one caller. -func denyInternal(d *rpc.Handler) { - for _, method := range []string{ - "store.Close", - "system.SetQuit", - "system.SetNativeWindow", - "store.SetSessionGone", - "store.SetScheduleForfeited", - "store.SetSessionRenamed", - "store.SetProjectChanged", - "store.SetBranchOf", - "store.SetTranscriptOf", - "store.SetConversationsOf", - "store.SetCheckoutsOf", - "drop.Upload", - "drop.Save", - "drop.Purge", - "drop.SetPicker", - "drop.SetPromptLanguage", - "relay.Observe", - "relay.WorkerAnswered", - "relay.WorkerUnanswered", - "relay.SessionClosed", - "relay.SessionExited", - "terminal.CloseBy", - "relay.RunSchedules", - "relay.ParkResume", - "agentplugin.RepairRegistrations", - "agentplugin.SetEmit", - "agentplugin.SyncProfiles", - "relay.SetPlugins", - "relay.SetWorkerFinished", - "relay.SetPromptLanguage", - "spawn.CloseFinishedWorker", - "project.SetAccounts", - "project.SetProjects", - "quota.SetSessions", - "quota.ReportClaude", - "terminal.SessionAccount", - "terminal.SetDropDir", - "terminal.SetRateLimitReports", - "terminal.SetUsageLimit", - "terminal.RunLimitWatch", - "terminal.SetWorkerAnswer", - "terminal.SetWorkerUnanswered", - "terminal.SetSessionClosed", - "terminal.SetSessionExited", - "terminal.SetStartReports", - "terminal.SetPromptLanguage", - "terminal.EnqueueModCommand", - "terminal.RunModCommand", - "terminal.SubmitPrompt", - "store.SetSessionProfile", - "store.ForgetClaudeProfile", - "store.ForgetCodexProfile", - "store.ForgetOMPProfile", - "store.ForgetXDGProfile", - "claudeprofile.SetLichPlugin", - "claudeprofile.SetSessions", - "codexprofile.SetLichPlugin", - "codexprofile.SetSessions", - "codexprofile.SetAccount", - "ompprofile.SetSessions", - "opencodeprofile.SetSessions", - "cursorprofile.SetSessions", - "kiroprofile.SetSessions", - } { - d.Deny(method) - } -} - // run serves the loopback listener, opens the window on it (the native one, // a process of its own) and serves until lich is told to quit. The window is // not the app's lifecycle: closing it leaves every session running, and a @@ -716,13 +576,6 @@ func handleBindFailure(configDir string, cause error, openRunning func(string, * os.Exit(1) } -// windowRaiseEvent asks the native window on /events to come forward, and -// windowCloseEvent to close, lich quitting. -const ( - windowRaiseEvent = "window-raise" - windowCloseEvent = "window-close" -) - // windowLeaveWait bounds waiting for the native window to end once told to // close. It ends at once (os.Exit on the event); the bound is for a window // that never reads it, which must not keep lich from quitting. @@ -734,7 +587,7 @@ const windowLeaveWait = 5 * time.Second func closeWindow(hub *events.Hub) func() { return func() { gone := hub.Gone() - hub.Emit(windowCloseEvent, nil) + events.Send(hub.Emit, api.WindowCloseEvent, api.None{}) select { case <-gone: case <-time.After(windowLeaveWait): @@ -750,7 +603,7 @@ func raiseNative(hub *events.Hub) func() bool { if !hub.Attached() { return false } - hub.Emit(windowRaiseEvent, nil) + events.Send(hub.Emit, api.WindowRaiseEvent, api.None{}) return true } } diff --git a/main_test.go b/main_test.go index bf96c5d56..264847fed 100644 --- a/main_test.go +++ b/main_test.go @@ -2,136 +2,17 @@ package main import ( "context" - "net/http" "net/http/httptest" - "reflect" "strings" "testing" "time" "github.com/coder/websocket" - "github.com/lichdotdev/lich/internal/agentplugin" - "github.com/lichdotdev/lich/internal/drop" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" - "github.com/lichdotdev/lich/internal/project" - "github.com/lichdotdev/lich/internal/relay" - "github.com/lichdotdev/lich/internal/rpc" - "github.com/lichdotdev/lich/internal/store" - "github.com/lichdotdev/lich/internal/system" - "github.com/lichdotdev/lich/internal/terminal" ) -// denied spells out denyInternal's list a second time, and names the type each -// entry belongs to. Reading the list under test would prove only that Go can -// range over a slice; written out here, an entry dropped from denyInternal -// fails TestDeniedMethodsAreUnreachable and an entry that names nothing — -// Deny takes any string — fails TestDeniedMethodsExist. -var denied = map[string]reflect.Type{ - "store.Close": reflect.TypeFor[*store.Service](), - "system.SetNativeWindow": reflect.TypeFor[*system.Service](), - "store.SetSessionGone": reflect.TypeFor[*store.Service](), - "store.SetProjectChanged": reflect.TypeFor[*store.Service](), - "store.SetConversationsOf": reflect.TypeFor[*store.Service](), - "store.SetCheckoutsOf": reflect.TypeFor[*store.Service](), - "drop.Upload": reflect.TypeFor[*drop.Service](), - "drop.Save": reflect.TypeFor[*drop.Service](), - "drop.Purge": reflect.TypeFor[*drop.Service](), - "drop.SetPicker": reflect.TypeFor[*drop.Service](), - "drop.SetPromptLanguage": reflect.TypeFor[*drop.Service](), - "relay.Observe": reflect.TypeFor[*relay.Service](), - "relay.RunSchedules": reflect.TypeFor[*relay.Service](), - "relay.ParkResume": reflect.TypeFor[*relay.Service](), - "agentplugin.RepairRegistrations": reflect.TypeFor[*agentplugin.Service](), - "agentplugin.SetEmit": reflect.TypeFor[*agentplugin.Service](), - "agentplugin.SyncProfiles": reflect.TypeFor[*agentplugin.Service](), - "relay.SetPlugins": reflect.TypeFor[*relay.Service](), - "relay.SetPromptLanguage": reflect.TypeFor[*relay.Service](), - "terminal.SetPromptLanguage": reflect.TypeFor[*terminal.Service](), - "project.SetAccounts": reflect.TypeFor[*project.Service](), - "project.SetProjects": reflect.TypeFor[*project.Service](), - "terminal.SetDropDir": reflect.TypeFor[*terminal.Service](), - "terminal.SetUsageLimit": reflect.TypeFor[*terminal.Service](), - "terminal.RunLimitWatch": reflect.TypeFor[*terminal.Service](), - "terminal.EnqueueModCommand": reflect.TypeFor[*terminal.Service](), - "terminal.RunModCommand": reflect.TypeFor[*terminal.Service](), - "terminal.SubmitPrompt": reflect.TypeFor[*terminal.Service](), - "terminal.CloseBy": reflect.TypeFor[*terminal.Service](), -} - -// stubService stands in for the registered services: dispatch resolves a -// method by name alone, and the real ones want a database, a config directory -// and a running terminal. -type stubService struct{} - -func (stubService) Close() error { return nil } -func (stubService) CloseBy() error { return nil } -func (stubService) SetSessionGone() error { return nil } -func (stubService) SetProjectChanged() error { return nil } -func (stubService) Upload() error { return nil } -func (stubService) Save() error { return nil } -func (stubService) Purge() error { return nil } -func (stubService) SetPicker() error { return nil } -func (stubService) Observe() error { return nil } -func (stubService) RunSchedules() error { return nil } -func (stubService) ParkResume() error { return nil } -func (stubService) SetUsageLimit() error { return nil } -func (stubService) RunLimitWatch() error { return nil } -func (stubService) RepairRegistrations() error { return nil } -func (stubService) SetEmit() error { return nil } -func (stubService) SyncProfiles() error { return nil } -func (stubService) SetPlugins() error { return nil } -func (stubService) SetPromptLanguage() error { return nil } -func (stubService) SetAccounts() error { return nil } -func (stubService) SetProjects() error { return nil } -func (stubService) SetDropDir() error { return nil } -func (stubService) SetConversationsOf() error { return nil } -func (stubService) SetCheckoutsOf() error { return nil } -func (stubService) EnqueueModCommand() error { return nil } -func (stubService) RunModCommand() error { return nil } -func (stubService) SubmitPrompt() error { return nil } -func (stubService) Allowed() error { return nil } - -func denyingDispatcher() *rpc.Handler { - d := rpc.New() - for _, service := range []string{"store", "drop", "relay", "project", "terminal", "agentplugin"} { - d.Register(service, stubService{}) - } - denyInternal(d) - return d -} - -func post(t *testing.T, d *rpc.Handler, method string) *httptest.ResponseRecorder { - t.Helper() - req := httptest.NewRequest(http.MethodPost, "/rpc/"+method, strings.NewReader("[]")) - rec := httptest.NewRecorder() - d.ServeHTTP(rec, req) - return rec -} - -func TestDeniedMethodsAreUnreachable(t *testing.T) { - d := denyingDispatcher() - for method := range denied { - if rec := post(t, d, method); rec.Code != http.StatusNotFound { - t.Errorf("POST /rpc/%s: want 404, got %d", method, rec.Code) - } - } - // The control: the same registration answers everything else, so the 404s - // above are the deny list and not a service that was never registered. - if rec := post(t, d, "relay.Allowed"); rec.Code != http.StatusOK { - t.Errorf("POST /rpc/relay.Allowed: want 200, got %d", rec.Code) - } -} - -func TestDeniedMethodsExist(t *testing.T) { - for name, service := range denied { - _, method, _ := strings.Cut(name, ".") - if _, ok := service.MethodByName(method); !ok { - t.Errorf("%s: %s has no method %s — the Deny entry hides nothing", name, service, method) - } - } -} - // closeWindow returns once the window that read window-close is gone, not as // soon as the event is sent: a Windows update replaces lich-native.exe right // after lich exits. @@ -168,8 +49,8 @@ func TestCloseWindowWaitsForTheWindowToLeave(t *testing.T) { closeWindow(hub)() waited := time.Since(start) - if got := <-read; !strings.Contains(got, windowCloseEvent) { - t.Fatalf("window read %q, want the %s event", got, windowCloseEvent) + if got := <-read; !strings.Contains(got, api.WindowCloseEvent.Name) { + t.Fatalf("window read %q, want the %s event", got, api.WindowCloseEvent.Name) } if waited < leaveDelay || waited >= windowLeaveWait { t.Fatalf("closeWindow returned after %v, want once the window left (%v)", waited, leaveDelay) diff --git a/native/backend/api.go b/native/backend/api.go new file mode 100644 index 000000000..014d1dfea --- /dev/null +++ b/native/backend/api.go @@ -0,0 +1,131 @@ +package backend + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/lichdotdev/lich/api" +) + +type Project = api.Project + +type Session = api.Session + +type DiffStats = api.DiffStats + +// LoadState returns every open project with its sessions. +func (c *Client) LoadState(ctx context.Context) ([]Project, error) { + return callMethod(ctx, c, api.StoreLoadState, api.None{}) +} + +// AddProject opens path as a project under id. +func (c *Client) AddProject(ctx context.Context, id, name, path string) error { + _, err := callMethod(ctx, c, api.StoreAddProject, api.AddProjectOptions{ID: id, Name: name, Path: path}) + return err +} + +// AddSession records a new session row; Start then spawns its process. +func (c *Client) AddSession(ctx context.Context, projectID, sessionID, label, kind, path string, nextSeq int) error { + _, err := callMethod(ctx, c, api.StoreAddSession, api.AddSessionOptions{ProjectID: projectID, SessionID: sessionID, Label: label, Kind: kind, Path: path, NextSeq: nextSeq}) + return err +} + +// SetActiveSession records which session a project shows. +func (c *Client) SetActiveSession(ctx context.Context, projectID, sessionID string) error { + _, err := callMethod(ctx, c, api.StoreSetActiveSession, api.ActiveSessionOptions{ProjectID: projectID, SessionID: sessionID}) + return err +} + +// Replay returns the tail of a session's output, to write into a fresh +// terminal before live output is applied. +func (c *Client) Replay(ctx context.Context, id string) ([]byte, error) { + b64, err := callMethod(ctx, c, api.TerminalReplay, api.SessionIDOptions{ID: id}) + if err != nil { + return nil, err + } + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("terminal.Replay: %w", err) + } + return data, nil +} + +type ConversationReported = api.SessionConversation + +// Start spawns the session's process at cols x rows; a no-op when it already +// runs. resume is a provider conversation id to reopen, "" to start fresh. +func (c *Client) Start(ctx context.Context, id, projectID, cwd, kind, resume string, cols, rows int) error { + _, err := callMethod(ctx, c, api.TerminalStart, api.StartOptions{ + ID: id, ProjectID: projectID, Cwd: cwd, Kind: kind, Resume: resume, Cols: cols, Rows: rows, + }) + return err +} + +// Fork spawns the session's process branching the provider conversation +// parent: the conversation is copied into one of its own, and the card that +// runs parent goes on untouched. A no-op when the session already runs. +func (c *Client) Fork(ctx context.Context, id, projectID, cwd, kind, parent string, cols, rows int) error { + _, err := callMethod(ctx, c, api.TerminalStart, api.StartOptions{ + ID: id, ProjectID: projectID, Cwd: cwd, Kind: kind, Resume: parent, Fork: true, Cols: cols, Rows: rows, + }) + return err +} + +// Resize sets the session's PTY size. +func (c *Client) Resize(ctx context.Context, id string, cols, rows int) error { + _, err := callMethod(ctx, c, api.TerminalResize, api.ResizeOptions{ID: id, Cols: cols, Rows: rows}) + return err +} + +// SetVisible tells the backend whether the session is on screen, which sets +// how long it batches output before sending. +func (c *Client) SetVisible(ctx context.Context, id string, visible bool) error { + _, err := callMethod(ctx, c, api.TerminalSetVisible, api.SetVisibleOptions{ID: id, Visible: visible}) + return err +} + +// Diff returns a checkout's branch and change counts. +func (c *Client) Diff(ctx context.Context, path string) (DiffStats, error) { + return callMethod(ctx, c, api.ProjectDiff, api.PathOptions{Path: path}) +} + +// DiffText returns one unified diff of a checkout's staged, unstaged and +// untracked changes against HEAD. +func (c *Client) DiffText(ctx context.Context, path string) (string, error) { + return callMethod(ctx, c, api.ProjectDiffText, api.PathOptions{Path: path}) +} + +// FileLines returns lines from..to (1-based, inclusive) of rel at ref, "" for +// the working tree. The backend caps one answer at 500 lines, so a caller +// wanting more asks again from where the answer ended. +func (c *Client) FileLines(ctx context.Context, path, rel, ref string, from, to int) ([]string, error) { + return callMethod(ctx, c, api.ProjectFileLines, api.FileLinesOptions{Path: path, Rel: rel, Ref: ref, From: from, To: to}) +} + +type RevertLine = api.RevertLine + +type RevertResult = api.RevertResult + +// RevertLines puts these changed lines of rel back to HEAD, in the index too +// when it holds them. It fails when the file moved on since the diff was +// drawn. +func (c *Client) RevertLines(ctx context.Context, path, rel string, lines []RevertLine) (RevertResult, error) { + return callMethod(ctx, c, api.ProjectRevertLines, api.RevertLinesOptions{Path: path, Rel: rel, Lines: lines}) +} + +// GetSetting returns a setting's value, "" when unset. scope is a project id, +// "" for a global setting. +func (c *Client) GetSetting(ctx context.Context, key, scope string) (string, error) { + return callMethod(ctx, c, api.StoreGetSetting, api.GetSettingOptions{Key: key, ProjectID: scope}) +} + +// HomeProject is the project rooted at the user's home directory, the one the +// windows pin as Home. It is not opened by asking. +func (c *Client) HomeProject(ctx context.Context) (Project, error) { + p, err := callMethod(ctx, c, api.ProjectHome, api.None{}) + if err != nil || p == nil { + return Project{}, err + } + return Project{ID: p.ID, Name: p.Name, Path: p.Path}, nil +} diff --git a/native/lichclient/appupdate.go b/native/backend/appupdate.go similarity index 68% rename from native/lichclient/appupdate.go rename to native/backend/appupdate.go index 4b63542f8..95cbe8ecb 100644 --- a/native/lichclient/appupdate.go +++ b/native/backend/appupdate.go @@ -1,14 +1,12 @@ -package lichclient +package backend import ( "context" "fmt" "net/http" -) -// AppUpdateProgressEvent is appupdate.ProgressEventName: one step of -// ApplyUpdate, an AppUpdateProgress. -const AppUpdateProgressEvent = "appupdate-progress" + "github.com/lichdotdev/lich/api" +) // The phases of AppUpdateProgress. const ( @@ -16,19 +14,14 @@ const ( UpdatePhaseInstaller = "installer" ) -// AppUpdateProgress mirrors appupdate.Progress: Total is -1 when the asset -// came without a Content-Length. -type AppUpdateProgress struct { - Phase string `json:"phase"` - Received int64 `json:"received"` - Total int64 `json:"total"` -} +type AppUpdateProgress = api.AppUpdateProgress // ApplyUpdate downloads, verifies and hands the latest release to the // Windows installer, which closes and reopens lich. Only valid where // AppUpdateStatus answered CanSelfApply; it returns once the installer runs. func (c *Client) ApplyUpdate(ctx context.Context) error { - return c.Call(ctx, "appupdate.Apply", nil) + _, err := callMethod(ctx, c, api.AppUpdateApply, api.None{}) + return err } // Restart relaunches lich in place: the backend answers 204, then spawns its diff --git a/native/lichclient/appupdate_test.go b/native/backend/appupdate_test.go similarity index 98% rename from native/lichclient/appupdate_test.go rename to native/backend/appupdate_test.go index 678d95fb3..242fb567c 100644 --- a/native/lichclient/appupdate_test.go +++ b/native/backend/appupdate_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/blob.go b/native/backend/blob.go similarity index 98% rename from native/lichclient/blob.go rename to native/backend/blob.go index e952daaf8..1bff569cd 100644 --- a/native/lichclient/blob.go +++ b/native/backend/blob.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/blob_test.go b/native/backend/blob_test.go similarity index 98% rename from native/lichclient/blob_test.go rename to native/backend/blob_test.go index c2b460eab..e233aff21 100644 --- a/native/lichclient/blob_test.go +++ b/native/backend/blob_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/client.go b/native/backend/client.go similarity index 82% rename from native/lichclient/client.go rename to native/backend/client.go index 7eae051b9..eba730e52 100644 --- a/native/lichclient/client.go +++ b/native/backend/client.go @@ -1,11 +1,11 @@ -// Package lichclient talks to a lich backend: positional-argument RPC over +// Package backend talks to a lich backend: the RPC methods api declares, over // HTTP, the /events WebSocket for state changes, the /ws WebSocket for // terminal bytes, and /blob for a preview's raw bytes. // // The backend serves one client per socket and a new connection drops the // previous one, so a backend the lich window is using cannot also serve this // client. -package lichclient +package backend import ( "bytes" @@ -22,6 +22,7 @@ import ( "sync" "github.com/coder/websocket" + "github.com/lichdotdev/lich/api" ) // Runtime is what a running lich writes to /lich/runtime.json (or @@ -112,29 +113,27 @@ func (c *Client) url(scheme, path string) string { return u.String() } -// Call invokes service.Method with positional args and decodes the result -// into out, which may be nil for methods that return nothing. -func (c *Client) Call(ctx context.Context, method string, out any, args ...any) error { - if args == nil { - args = []any{} - } - body, err := json.Marshal(args) +// callMethod posts m and decodes its result: the one way this client reaches +// the backend, so every call is held to the types api declares. +func callMethod[Req, Resp any](ctx context.Context, c *Client, m api.Method[Req, Resp], req Req) (Resp, error) { + var out Resp + body, err := json.Marshal(m.Args(req)) if err != nil { - return fmt.Errorf("%s: %w", method, err) + return out, fmt.Errorf("%s: %w", m.Name, err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url("http", "/rpc/"+method), bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url("http", "/rpc/"+m.Name), bytes.NewReader(body)) if err != nil { - return fmt.Errorf("%s: %w", method, err) + return out, fmt.Errorf("%s: %w", m.Name, err) } - req.Header.Set("Content-Type", "application/json") - resp, err := c.http.Do(req) + httpReq.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(httpReq) if err != nil { - return fmt.Errorf("%s: %w", method, err) + return out, fmt.Errorf("%s: %w", m.Name, err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("%s: %w", method, err) + return out, fmt.Errorf("%s: %w", m.Name, err) } if resp.StatusCode != http.StatusOK { var failure struct { @@ -143,15 +142,12 @@ func (c *Client) Call(ctx context.Context, method string, out any, args ...any) if json.Unmarshal(data, &failure) != nil || failure.Error == "" { failure.Error = string(data) } - return &RemoteError{Method: method, Message: failure.Error, Status: resp.StatusCode} + return out, &RemoteError{Method: m.Name, Message: failure.Error, Status: resp.StatusCode} } - if out == nil { - return nil + if err := json.Unmarshal(data, &out); err != nil { + return out, fmt.Errorf("%s: decode result: %w", m.Name, err) } - if err := json.Unmarshal(data, out); err != nil { - return fmt.Errorf("%s: decode result: %w", method, err) - } - return nil + return out, nil } // RemoteError is a call the backend refused. Message is the backend's own @@ -181,6 +177,19 @@ type Event struct { Data json.RawMessage `json:"data"` } +// Decode reads ev's payload as the one api declares for e. It fails for an +// event of another name, so a payload is never read as the wrong type. +func Decode[T any](ev Event, e api.Event[T]) (T, error) { + var payload T + if ev.Name != e.Name { + return payload, fmt.Errorf("decode %s as %s", ev.Name, e.Name) + } + if err := json.Unmarshal(ev.Data, &payload); err != nil { + return payload, fmt.Errorf("%s: %w", ev.Name, err) + } + return payload, nil +} + // Events connects to /events and delivers its messages until ctx ends or the // connection drops, then closes the channel. Events sent while no client is // connected are lost: the backend does not replay them. diff --git a/native/lichclient/client_test.go b/native/backend/client_test.go similarity index 88% rename from native/lichclient/client_test.go rename to native/backend/client_test.go index d5c74ac95..4c8545ead 100644 --- a/native/lichclient/client_test.go +++ b/native/backend/client_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "bytes" @@ -15,7 +15,7 @@ import ( "testing" ) -// fakeBackend answers /rpc/ the way internal/rpc does: positional JSON args +// fakeBackend answers /rpc/ the way internal/rpc does: the JSON argument list // in, the result or {"error"} out. func fakeBackend(t *testing.T, handle func(method string, args []json.RawMessage) (any, int)) *Client { t.Helper() @@ -39,9 +39,9 @@ func fakeBackend(t *testing.T, handle func(method string, args []json.RawMessage return New(Runtime{Port: port, Token: "tok"}) } -func TestCallSendsPositionalArgsAndDecodes(t *testing.T) { +func TestCallSendsItsOptionsAndDecodes(t *testing.T) { c := fakeBackend(t, func(method string, args []json.RawMessage) (any, int) { - if method != "project.Diff" || len(args) != 1 || string(args[0]) != `"/repo"` { + if method != "project.Diff" || len(args) != 1 || string(args[0]) != `{"path":"/repo"}` { t.Errorf("got %s %s", method, args) } return DiffStats{Files: 2, Branch: "main"}, http.StatusOK @@ -81,7 +81,7 @@ func TestCallReturnsBackendError(t *testing.T) { c := fakeBackend(t, func(string, []json.RawMessage) (any, int) { return map[string]string{"error": "want 2 arguments, got 1"}, http.StatusBadRequest }) - err := c.Call(context.Background(), "store.RenameSession", nil, "id") + err := c.RenameSession(context.Background(), "id", "label") if err == nil || !strings.Contains(err.Error(), "want 2 arguments, got 1") { t.Fatalf("got %v", err) } @@ -91,7 +91,7 @@ func TestErrorTextIsTheBackendSentence(t *testing.T) { c := fakeBackend(t, func(string, []json.RawMessage) (any, int) { return map[string]string{"error": "no file manager found"}, http.StatusInternalServerError }) - err := c.Call(context.Background(), "system.OpenFolder", nil, "/tmp") + err := c.OpenFolder(context.Background(), "/tmp") if got := ErrorText(err); got != "no file manager found" { t.Fatalf("ErrorText = %q, want the backend's sentence alone", got) } @@ -124,13 +124,13 @@ func TestFrameRoundTrip(t *testing.T) { func TestRevertLinesSendsTheLinesAsTheBackendNamesThem(t *testing.T) { c := fakeBackend(t, func(method string, args []json.RawMessage) (any, int) { - want := `[{"side":"old","line":4,"text":"b"},{"side":"new","line":4,"text":"B"}]` - if method != "project.RevertLines" || len(args) != 3 || string(args[1]) != `"main.go"` || string(args[2]) != want { + want := `{"path":"/repo","rel":"main.go","lines":[{"side":"old","line":4,"text":"b"},{"side":"new","line":4,"text":"B"}]}` + if method != "project.RevertLines" || len(args) != 1 || string(args[0]) != want { t.Errorf("got %s %s", method, args) } return RevertResult{Patch: "p"}, http.StatusOK }) - r, err := c.RevertLines(context.Background(), "/repo", "main.go", []RevertLine{{"old", 4, "b"}, {"new", 4, "B"}}) + r, err := c.RevertLines(context.Background(), "/repo", "main.go", []RevertLine{{Side: "old", Line: 4, Text: "b"}, {Side: "new", Line: 4, Text: "B"}}) if err != nil || r.Patch != "p" { t.Fatalf("got %+v, %v", r, err) } @@ -138,7 +138,7 @@ func TestRevertLinesSendsTheLinesAsTheBackendNamesThem(t *testing.T) { func TestFileLinesSendsRefAndRange(t *testing.T) { c := fakeBackend(t, func(method string, args []json.RawMessage) (any, int) { - if method != "project.FileLines" || len(args) != 5 || string(args[2]) != `""` || string(args[3]) != "3" || string(args[4]) != "4" { + if method != "project.FileLines" || len(args) != 1 || string(args[0]) != `{"path":"/repo","rel":"main.go","ref":"","from":3,"to":4}` { t.Errorf("got %s %s", method, args) } return []string{"x", "y"}, http.StatusOK diff --git a/native/lichclient/drop.go b/native/backend/drop.go similarity index 98% rename from native/lichclient/drop.go rename to native/backend/drop.go index a39c0dcc4..33e9f89bf 100644 --- a/native/lichclient/drop.go +++ b/native/backend/drop.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/env.go b/native/backend/env.go similarity index 97% rename from native/lichclient/env.go rename to native/backend/env.go index 7917f1fb8..c058f2b8d 100644 --- a/native/lichclient/env.go +++ b/native/backend/env.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "fmt" diff --git a/native/lichclient/env_test.go b/native/backend/env_test.go similarity index 98% rename from native/lichclient/env_test.go rename to native/backend/env_test.go index ad1fc7316..5e8f945bf 100644 --- a/native/lichclient/env_test.go +++ b/native/backend/env_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "strings" diff --git a/native/backend/footer.go b/native/backend/footer.go new file mode 100644 index 000000000..fb5ac8213 --- /dev/null +++ b/native/backend/footer.go @@ -0,0 +1,44 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type QuotaWindow = api.QuotaWindow + +type QuotaPlan = api.QuotaPlan + +type Attachment = api.DropAttachment + +// QuotaPlans reads every metered plan for the account sessionID spends; "" +// reads the machine's own login. The backend serves it from a five-minute +// cache. +func (c *Client) QuotaPlans(ctx context.Context, sessionID string) ([]QuotaPlan, error) { + return callMethod(ctx, c, api.QuotaPlans, api.QuotaPlansOptions{SessionID: sessionID}) +} + +// HandsOn is how long session id has been worked on, in whole seconds. +func (c *Client) HandsOn(ctx context.Context, id string) (int64, error) { + return callMethod(ctx, c, api.TerminalHandsOn, api.SessionIDOptions{ID: id}) +} + +// Attach opens the backend's file chooser for session sessionID. root is the +// session's checkout and confined whether it runs in the sandbox: a file +// outside what the session can read comes back as a copy's path. +func (c *Client) Attach(ctx context.Context, sessionID, root string, confined bool) (Attachment, error) { + return callMethod(ctx, c, api.DropAttach, api.AttachDropOptions{SessionID: sessionID, Root: root, Confined: confined}) +} + +// Write types data into session id's PTY. +func (c *Client) Write(ctx context.Context, id, data string) error { + _, err := callMethod(ctx, c, api.TerminalWrite, api.WriteOptions{ID: id, Data: data}) + return err +} + +// SetSetting writes a setting; scope is a project id, "" for a global one. +func (c *Client) SetSetting(ctx context.Context, key, scope, value string) error { + _, err := callMethod(ctx, c, api.StoreSetSetting, api.SetSettingOptions{Key: key, ProjectID: scope, Value: value}) + return err +} diff --git a/native/lichclient/footer_test.go b/native/backend/footer_test.go similarity index 79% rename from native/lichclient/footer_test.go rename to native/backend/footer_test.go index cb81e2806..a8c7eac23 100644 --- a/native/lichclient/footer_test.go +++ b/native/backend/footer_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" @@ -7,7 +7,7 @@ import ( "testing" ) -// rawArgs joins the positional args as the backend received them. +// rawArgs joins the argument list as the backend received it. func rawArgs(args []json.RawMessage) string { s := "" for i, a := range args { @@ -19,29 +19,29 @@ func rawArgs(args []json.RawMessage) string { return s } -func TestFooterCallsSendPositionalArgs(t *testing.T) { +func TestFooterCallsSendTheirOptions(t *testing.T) { ctx := context.Background() cases := []struct { name, method, args string result any call func(c *Client) error }{ - {"QuotaPlans", "quota.Plans", `"s1"`, []QuotaPlan{}, func(c *Client) error { + {"QuotaPlans", "quota.Plans", `{"sessionId":"s1"}`, []QuotaPlan{}, func(c *Client) error { _, err := c.QuotaPlans(ctx, "s1") return err }}, - {"HandsOn", "terminal.HandsOn", `"s1"`, 0, func(c *Client) error { + {"HandsOn", "terminal.HandsOn", `{"id":"s1"}`, 0, func(c *Client) error { _, err := c.HandsOn(ctx, "s1") return err }}, - {"Attach", "drop.Attach", `"s1","/repo",true`, Attachment{}, func(c *Client) error { + {"Attach", "drop.Attach", `{"sessionId":"s1","root":"/repo","confined":true}`, Attachment{}, func(c *Client) error { _, err := c.Attach(ctx, "s1", "/repo", true) return err }}, - {"Write", "terminal.Write", `"s1","hi"`, nil, func(c *Client) error { + {"Write", "terminal.Write", `{"id":"s1","data":"hi"}`, nil, func(c *Client) error { return c.Write(ctx, "s1", "hi") }}, - {"SetSetting", "store.SetSetting", `"footer.layout","","{}"`, nil, func(c *Client) error { + {"SetSetting", "store.SetSetting", `{"key":"footer.layout","projectId":"","value":"{}"}`, nil, func(c *Client) error { return c.SetSetting(ctx, "footer.layout", "", "{}") }}, } diff --git a/native/lichclient/live_test.go b/native/backend/live_test.go similarity index 99% rename from native/lichclient/live_test.go rename to native/backend/live_test.go index 4056baff8..a2bb7342f 100644 --- a/native/lichclient/live_test.go +++ b/native/backend/live_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "bytes" diff --git a/native/backend/palette.go b/native/backend/palette.go new file mode 100644 index 000000000..6b67dc368 --- /dev/null +++ b/native/backend/palette.go @@ -0,0 +1,68 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type ClosedSession = api.ParkedSession + +type ClosedHistory = api.ClosedHistory + +type ExternalSession = api.ExternalSession + +type TranscriptMatch = api.TranscriptMatch + +// RecentProjectsMatching is RecentProjects narrowed to the closed projects +// whose name or path hold every word of term. +func (c *Client) RecentProjectsMatching(ctx context.Context, term string) ([]RecentProject, error) { + return callMethod(ctx, c, api.StoreRecentProjects, api.SearchTermOptions{Term: term}) +} + +// ClosedProjectCountMatching counts every closed project matching term, past +// the page RecentProjectsMatching answers with. +func (c *Client) ClosedProjectCountMatching(ctx context.Context, term string) (int, error) { + return callMethod(ctx, c, api.StoreClosedProjectCount, api.SearchTermOptions{Term: term}) +} + +// ClosedSessions searches the parked sessions by name, project, path, branch +// and conversation; an empty term is the plain history, newest close first. +// A non-empty term also starts indexing any conversation not indexed yet. +func (c *Client) ClosedSessions(ctx context.Context, term string) (ClosedHistory, error) { + return callMethod(ctx, c, api.StoreClosedSessions, api.SearchTermOptions{Term: term}) +} + +// ExternalSessions lists the conversations started outside lich in a known +// project's checkouts that no session row holds, newest first. +func (c *Client) ExternalSessions(ctx context.Context) ([]ExternalSession, error) { + return callMethod(ctx, c, api.StoreExternalSessions, api.None{}) +} + +// AdoptExternalSession files an external conversation as a parked session of +// projectID under sessionID, which ReopenSession then brings back. It fails +// when a session already holds the conversation. +func (c *Client) AdoptExternalSession(ctx context.Context, projectID, sessionID, kind, path, providerSessionID, label string) error { + _, err := callMethod(ctx, c, api.StoreAdoptExternalSession, api.AdoptExternalSessionOptions{ProjectID: projectID, SessionID: sessionID, Kind: kind, Path: path, ProviderSessionID: providerSessionID, Label: label}) + return err +} + +// ForgetSession deletes one parked session for good. It fails on a session +// that is open. +func (c *Client) ForgetSession(ctx context.Context, sessionID string) error { + _, err := callMethod(ctx, c, api.StoreForgetSession, api.SessionIDOptions{ID: sessionID}) + return err +} + +// SearchTranscripts finds which of the sessions ids have talked about query +// and what they said, in the order the sessions were given. A query under +// three characters is refused and answers none. +func (c *Client) SearchTranscripts(ctx context.Context, ids []string, query string) ([]TranscriptMatch, error) { + return callMethod(ctx, c, api.TerminalSearchTranscripts, api.SearchTranscriptsOptions{IDs: ids, Query: query}) +} + +// BranchesOf names the branch each checkout path is on now; a path that is no +// repository is absent from the answer. +func (c *Client) BranchesOf(ctx context.Context, paths []string) (map[string]string, error) { + return callMethod(ctx, c, api.ProjectBranchesOf, api.PathsOptions{Paths: paths}) +} diff --git a/native/lichclient/palette_test.go b/native/backend/palette_test.go similarity index 78% rename from native/lichclient/palette_test.go rename to native/backend/palette_test.go index 9a49f4f44..a89b63ea7 100644 --- a/native/lichclient/palette_test.go +++ b/native/backend/palette_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" @@ -7,22 +7,22 @@ import ( "testing" ) -func TestPaletteCallsSendPositionalArgs(t *testing.T) { +func TestPaletteCallsSendTheirOptions(t *testing.T) { ctx := context.Background() cases := []struct { name, method, args string result any call func(c *Client) error }{ - {"RecentProjectsMatching", "store.RecentProjects", `"plug"`, []RecentProject{}, func(c *Client) error { + {"RecentProjectsMatching", "store.RecentProjects", `{"term":"plug"}`, []RecentProject{}, func(c *Client) error { _, err := c.RecentProjectsMatching(ctx, "plug") return err }}, - {"ClosedProjectCountMatching", "store.ClosedProjectCount", `"plug"`, 2, func(c *Client) error { + {"ClosedProjectCountMatching", "store.ClosedProjectCount", `{"term":"plug"}`, 2, func(c *Client) error { _, err := c.ClosedProjectCountMatching(ctx, "plug") return err }}, - {"ClosedSessions", "store.ClosedSessions", `"relay"`, ClosedHistory{}, func(c *Client) error { + {"ClosedSessions", "store.ClosedSessions", `{"term":"relay"}`, ClosedHistory{}, func(c *Client) error { _, err := c.ClosedSessions(ctx, "relay") return err }}, @@ -30,17 +30,17 @@ func TestPaletteCallsSendPositionalArgs(t *testing.T) { _, err := c.ExternalSessions(ctx) return err }}, - {"AdoptExternalSession", "store.AdoptExternalSession", `"p","s1","codex","/wt","c1","Port"`, nil, func(c *Client) error { + {"AdoptExternalSession", "store.AdoptExternalSession", `{"projectId":"p","sessionId":"s1","kind":"codex","path":"/wt","providerSessionId":"c1","label":"Port"}`, nil, func(c *Client) error { return c.AdoptExternalSession(ctx, "p", "s1", "codex", "/wt", "c1", "Port") }}, - {"ForgetSession", "store.ForgetSession", `"h1"`, nil, func(c *Client) error { + {"ForgetSession", "store.ForgetSession", `{"id":"h1"}`, nil, func(c *Client) error { return c.ForgetSession(ctx, "h1") }}, - {"SearchTranscripts", "terminal.SearchTranscripts", `["s1","s2"],"flaky"`, []TranscriptMatch{}, func(c *Client) error { + {"SearchTranscripts", "terminal.SearchTranscripts", `{"ids":["s1","s2"],"query":"flaky"}`, []TranscriptMatch{}, func(c *Client) error { _, err := c.SearchTranscripts(ctx, []string{"s1", "s2"}, "flaky") return err }}, - {"BranchesOf", "project.BranchesOf", `["/a"]`, map[string]string{}, func(c *Client) error { + {"BranchesOf", "project.BranchesOf", `{"paths":["/a"]}`, map[string]string{}, func(c *Client) error { _, err := c.BranchesOf(ctx, []string{"/a"}) return err }}, diff --git a/native/backend/profile.go b/native/backend/profile.go new file mode 100644 index 000000000..79a56cfce --- /dev/null +++ b/native/backend/profile.go @@ -0,0 +1,175 @@ +package backend + +import ( + "context" + "errors" + "strings" + + "github.com/lichdotdev/lich/api" +) + +// profileGoneText is what internal/claudeprofile.ErrProfileGone and +// internal/codexprofile.ErrProfileGone both end in, which a spawn in a profile +// deleted since answers. +const profileGoneText = "profile no longer exists" + +type Profile = api.ProfileInfo + +type ProfileUpdate = api.ProfileUpdate + +type ProfilePlugin = api.ProfilePlugin + +type ProfilePluginResult = api.ProfilePluginResult + +type ProfileLogin = api.ProfileLogin + +type ProfileChanged = api.ProfileChanged + +type ProfileMoved = api.SessionProfile + +// ProfileAPI is one provider's profile service: the claudeprofile, codexprofile +// or ompprofile one, which answer the same calls. A Codex login has no sign-in +// code to hand over, so LoginCode is Claude Code's alone, and Plugins and +// InstallPlugins are Claude Code's and Codex's. oh-my-pi's login is a terminal +// card's (LoginCommand), so it has no Login, LoginCode or CancelLogin, and no +// plan. A call the provider has no method for fails with ErrNoProfileCall. +type ProfileAPI struct { + c *Client + methods api.ProfileMethods + login *api.ProfileLoginMethods + plugins *api.ProfilePluginMethods + loginCode *api.Method[api.LoginCodeOptions, api.None] + plan *api.Method[api.ProfilePlanOptions, api.QuotaPlan] +} + +// ErrNoProfileCall is a profile call the provider's service does not answer. +var ErrNoProfileCall = errors.New("this provider's profiles have no such call") + +// ClaudeProfiles is the Claude Code profile service. +func (c *Client) ClaudeProfiles() ProfileAPI { + return ProfileAPI{ + c: c, methods: api.ClaudeProfiles, + login: &api.ClaudeProfileLogin, plugins: &api.ClaudeProfilePlugins, + loginCode: &api.ClaudeProfileLoginCode, plan: &api.QuotaProfilePlan, + } +} + +// CodexProfiles is the Codex profile service. +func (c *Client) CodexProfiles() ProfileAPI { + return ProfileAPI{ + c: c, methods: api.CodexProfiles, + login: &api.CodexProfileLogin, plugins: &api.CodexProfilePlugins, plan: &api.QuotaCodexProfilePlan, + } +} + +// OMPProfiles is the oh-my-pi profile service. +func (c *Client) OMPProfiles() ProfileAPI { + return ProfileAPI{c: c, methods: api.OMPProfiles} +} + +// XDGProfiles is the profile service of opencode, Cursor CLI or Kiro CLI, by +// provider id (xdgprofile.Service, "profile"). +func (c *Client) XDGProfiles(kind string) ProfileAPI { + return ProfileAPI{c: c, methods: api.XDGProfiles[kind]} +} + +// List lists every profile with its login state; the backend runs one CLI per +// profile to answer. +func (a ProfileAPI) List(ctx context.Context) ([]Profile, error) { + return callMethod(ctx, a.c, a.methods.Profiles, api.None{}) +} + +// Create makes a profile named name and returns it; logging it in is Login's. +func (a ProfileAPI) Create(ctx context.Context, name string) (Profile, error) { + p, err := callMethod(ctx, a.c, a.methods.CreateProfile, api.CreateProfileOptions{Name: name}) + return Profile{Profile: p}, err +} + +// Update changes a profile's name, color or image and returns it as the backend +// now lists it, without its login state. +func (a ProfileAPI) Update(ctx context.Context, slug string, u ProfileUpdate) (Profile, error) { + p, err := callMethod(ctx, a.c, a.methods.UpdateProfile, api.UpdateProfileOptions{Slug: slug, Update: u}) + return Profile{Profile: p}, err +} + +// Delete removes a profile and its login; the backend refuses while a session +// runs in it. +func (a ProfileAPI) Delete(ctx context.Context, slug string) error { + _, err := callMethod(ctx, a.c, a.methods.DeleteProfile, api.ProfileSlugOptions{Slug: slug}) + return err +} + +// Plugins lists the plugins a profile has at user scope, the ones a port can +// carry to another profile. Claude Code and Codex only. +func (a ProfileAPI) Plugins(ctx context.Context, slug string) ([]ProfilePlugin, error) { + if a.plugins == nil { + return nil, ErrNoProfileCall + } + return callMethod(ctx, a.c, a.plugins.Plugins, api.ProfileSlugOptions{Slug: slug}) +} + +// InstallPlugins installs keys (plugin@marketplace) into a logged-in profile, +// one result per key. Claude Code and Codex only. +func (a ProfileAPI) InstallPlugins(ctx context.Context, slug string, keys []string) ([]ProfilePluginResult, error) { + if a.plugins == nil { + return nil, ErrNoProfileCall + } + return callMethod(ctx, a.c, a.plugins.InstallPlugins, api.InstallPluginsOptions{Slug: slug, Keys: keys}) +} + +// Login starts the provider's login as a profile and returns once the CLI +// runs; its progress arrives as ClaudeProfileLoginEvent or +// CodexProfileLoginEvent events. +func (a ProfileAPI) Login(ctx context.Context, slug string) error { + if a.login == nil { + return ErrNoProfileCall + } + _, err := callMethod(ctx, a.c, a.login.Login, api.ProfileSlugOptions{Slug: slug}) + return err +} + +// LoginCode hands a running login the code the sign-in page showed. Claude Code +// only. +func (a ProfileAPI) LoginCode(ctx context.Context, slug, code string) error { + if a.loginCode == nil { + return ErrNoProfileCall + } + _, err := callMethod(ctx, a.c, *a.loginCode, api.LoginCodeOptions{Slug: slug, Code: code}) + return err +} + +// CancelLogin ends a login lich started. +func (a ProfileAPI) CancelLogin(ctx context.Context, slug string) error { + if a.login == nil { + return ErrNoProfileCall + } + _, err := callMethod(ctx, a.c, a.login.CancelLogin, api.ProfileSlugOptions{Slug: slug}) + return err +} + +// LoginCommand is the command line that logs a profile in from a terminal. +func (a ProfileAPI) LoginCommand(ctx context.Context, slug string) (string, error) { + return callMethod(ctx, a.c, a.methods.LoginCommand, api.ProfileSlugOptions{Slug: slug}) +} + +// Plan reads one profile's plan; "personal" is the machine's own reading. +func (a ProfileAPI) Plan(ctx context.Context, slug string) (QuotaPlan, error) { + if a.plan == nil { + return QuotaPlan{}, ErrNoProfileCall + } + return callMethod(ctx, a.c, *a.plan, api.ProfilePlanOptions{Profile: slug}) +} + +// ContinueIn moves a session's conversation to another profile of its provider +// and carries on there. The backend refuses a turn in progress, and a provider +// whose conversations cannot move (opencode). +func (c *Client) ContinueIn(ctx context.Context, id, slug string) error { + _, err := callMethod(ctx, c, api.TerminalContinueIn, api.ContinueInOptions{ID: id, Profile: slug}) + return err +} + +// ProfileGone reports whether err is a spawn refused for a profile deleted +// since the session was born in it. +func ProfileGone(err error) bool { + return err != nil && strings.Contains(ErrorText(err), profileGoneText) +} diff --git a/native/lichclient/profile_test.go b/native/backend/profile_test.go similarity index 70% rename from native/lichclient/profile_test.go rename to native/backend/profile_test.go index ca9f30631..a56314040 100644 --- a/native/lichclient/profile_test.go +++ b/native/backend/profile_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" @@ -8,7 +8,7 @@ import ( "testing" ) -func TestProfileCallsSendPositionalArgs(t *testing.T) { +func TestProfileCallsSendTheirOptions(t *testing.T) { ctx := context.Background() cases := []struct { name, method, args string @@ -19,40 +19,40 @@ func TestProfileCallsSendPositionalArgs(t *testing.T) { _, err := c.ClaudeProfiles().List(ctx) return err }}, - {"Create", "claudeprofile.CreateProfile", `"Work"`, Profile{Slug: "work"}, func(c *Client) error { + {"Create", "claudeprofile.CreateProfile", `{"name":"Work"}`, Profile{Slug: "work"}, func(c *Client) error { _, err := c.ClaudeProfiles().Create(ctx, "Work") return err }}, - {"Update", "claudeprofile.UpdateProfile", `"work",{"name":"Day job","color":"teal"}`, Profile{Slug: "work"}, func(c *Client) error { + {"Update", "claudeprofile.UpdateProfile", `{"slug":"work","update":{"name":"Day job","color":"teal"}}`, Profile{Slug: "work"}, func(c *Client) error { teal := "teal" _, err := c.ClaudeProfiles().Update(ctx, "work", ProfileUpdate{Name: "Day job", Color: &teal}) return err }}, - {"Delete", "claudeprofile.DeleteProfile", `"work"`, nil, func(c *Client) error { + {"Delete", "claudeprofile.DeleteProfile", `{"slug":"work"}`, nil, func(c *Client) error { return c.ClaudeProfiles().Delete(ctx, "work") }}, - {"Plugins", "claudeprofile.Plugins", `"personal"`, []ProfilePlugin{}, func(c *Client) error { + {"Plugins", "claudeprofile.Plugins", `{"slug":"personal"}`, []ProfilePlugin{}, func(c *Client) error { _, err := c.ClaudeProfiles().Plugins(ctx, "personal") return err }}, - {"InstallPlugins", "claudeprofile.InstallPlugins", `"work",["a@m","b@m"]`, []ProfilePluginResult{}, func(c *Client) error { + {"InstallPlugins", "claudeprofile.InstallPlugins", `{"slug":"work","keys":["a@m","b@m"]}`, []ProfilePluginResult{}, func(c *Client) error { _, err := c.ClaudeProfiles().InstallPlugins(ctx, "work", []string{"a@m", "b@m"}) return err }}, - {"Login", "claudeprofile.Login", `"work"`, nil, func(c *Client) error { + {"Login", "claudeprofile.Login", `{"slug":"work"}`, nil, func(c *Client) error { return c.ClaudeProfiles().Login(ctx, "work") }}, - {"LoginCode", "claudeprofile.LoginCode", `"work","abc"`, nil, func(c *Client) error { + {"LoginCode", "claudeprofile.LoginCode", `{"slug":"work","code":"abc"}`, nil, func(c *Client) error { return c.ClaudeProfiles().LoginCode(ctx, "work", "abc") }}, - {"CancelLogin", "claudeprofile.CancelLogin", `"work"`, nil, func(c *Client) error { + {"CancelLogin", "claudeprofile.CancelLogin", `{"slug":"work"}`, nil, func(c *Client) error { return c.ClaudeProfiles().CancelLogin(ctx, "work") }}, - {"LoginCommand", "claudeprofile.LoginCommand", `"work"`, "claude auth login", func(c *Client) error { + {"LoginCommand", "claudeprofile.LoginCommand", `{"slug":"work"}`, "claude auth login", func(c *Client) error { _, err := c.ClaudeProfiles().LoginCommand(ctx, "work") return err }}, - {"ProfilePlan", "quota.ProfilePlan", `"work"`, QuotaPlan{}, func(c *Client) error { + {"ProfilePlan", "quota.ProfilePlan", `{"profile":"work"}`, QuotaPlan{}, func(c *Client) error { _, err := c.ClaudeProfiles().Plan(ctx, "work") return err }}, @@ -60,10 +60,10 @@ func TestProfileCallsSendPositionalArgs(t *testing.T) { _, err := c.CodexProfiles().List(ctx) return err }}, - {"CodexLogin", "codexprofile.Login", `"work"`, nil, func(c *Client) error { + {"CodexLogin", "codexprofile.Login", `{"slug":"work"}`, nil, func(c *Client) error { return c.CodexProfiles().Login(ctx, "work") }}, - {"CodexProfilePlan", "quota.CodexProfilePlan", `"work"`, QuotaPlan{}, func(c *Client) error { + {"CodexProfilePlan", "quota.CodexProfilePlan", `{"profile":"work"}`, QuotaPlan{}, func(c *Client) error { _, err := c.CodexProfiles().Plan(ctx, "work") return err }}, @@ -71,7 +71,7 @@ func TestProfileCallsSendPositionalArgs(t *testing.T) { _, err := c.OMPProfiles().List(ctx) return err }}, - {"OMPLoginCommand", "ompprofile.LoginCommand", `"work"`, "env OMP_PROFILE='work' omp login", func(c *Client) error { + {"OMPLoginCommand", "ompprofile.LoginCommand", `{"slug":"work"}`, "env OMP_PROFILE='work' omp login", func(c *Client) error { _, err := c.OMPProfiles().LoginCommand(ctx, "work") return err }}, @@ -79,11 +79,11 @@ func TestProfileCallsSendPositionalArgs(t *testing.T) { _, err := c.XDGProfiles("kiro").List(ctx) return err }}, - {"XDGLoginCommand", "cursorprofile.LoginCommand", `"work"`, "cursor-agent login", func(c *Client) error { + {"XDGLoginCommand", "cursorprofile.LoginCommand", `{"slug":"work"}`, "cursor-agent login", func(c *Client) error { _, err := c.XDGProfiles("cursor").LoginCommand(ctx, "work") return err }}, - {"ContinueIn", "terminal.ContinueIn", `"s1","work"`, nil, func(c *Client) error { + {"ContinueIn", "terminal.ContinueIn", `{"id":"s1","profile":"work"}`, nil, func(c *Client) error { return c.ContinueIn(ctx, "s1", "work") }}, } diff --git a/native/backend/project.go b/native/backend/project.go new file mode 100644 index 000000000..20cc863d9 --- /dev/null +++ b/native/backend/project.go @@ -0,0 +1,122 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type BaseStatus = api.BaseStatus + +type PullRequest = api.PullRequest + +// BaseStatus returns where path stands against its base, nil when it has no +// base to stand against (the base branch itself, no remote). +func (c *Client) BaseStatus(ctx context.Context, path string) (*BaseStatus, error) { + return callMethod(ctx, c, api.ProjectBaseStatus, api.PathOptions{Path: path}) +} + +// PullRequest returns the open pull request of path's branch through gh, nil +// when there is none or gh cannot answer. Each call is a network round trip. +func (c *Client) PullRequest(ctx context.Context, path string) (*PullRequest, error) { + return callMethod(ctx, c, api.ProjectPullRequest, api.PathOptions{Path: path}) +} + +// OpenExternal opens url in the user's browser. +func (c *Client) OpenExternal(ctx context.Context, url string) error { + _, err := callMethod(ctx, c, api.SystemOpenExternal, api.OpenExternalOptions{URL: url}) + return err +} + +// OpenFolderInEditor opens dir in the user's editor. A GUI editor is launched +// detached and the reply is ""; a terminal editor comes back as the command +// line to run in a shell at dir. +func (c *Client) OpenFolderInEditor(ctx context.Context, dir string) (string, error) { + return callMethod(ctx, c, api.SystemOpenFolderInEditor, api.OpenFolderOptions{Dir: dir}) +} + +// OpenFolder opens dir in the file manager. +func (c *Client) OpenFolder(ctx context.Context, dir string) error { + _, err := callMethod(ctx, c, api.SystemOpenFolder, api.OpenFolderOptions{Dir: dir}) + return err +} + +type Diagnostics = api.Diagnostics + +func (c *Client) Diagnostics(ctx context.Context) (Diagnostics, error) { + return callMethod(ctx, c, api.SystemDiagnostics, api.None{}) +} + +// RevealLog opens the log's directory in the file manager; it fails when +// there is no log file. +func (c *Client) RevealLog(ctx context.Context) error { + _, err := callMethod(ctx, c, api.SystemRevealLog, api.None{}) + return err +} + +// TakeUncleanExit reports whether lich's run before this one ended without +// quitting cleanly. The backend clears the flag on the first ask, so only +// the first window to ask is told. +func (c *Client) TakeUncleanExit(ctx context.Context) (bool, error) { + return callMethod(ctx, c, api.SystemTakeUncleanExit, api.None{}) +} + +// OpenFile opens rel, a file of the checkout at dir, in the application the +// desktop opens its type with, never in the user's editor. +func (c *Client) OpenFile(ctx context.Context, dir, rel string) error { + _, err := callMethod(ctx, c, api.SystemOpenFile, api.OpenFileOptions{Dir: dir, Rel: rel}) + return err +} + +// SandboxBackend names what confines a sandboxed session on this machine, "" +// when nothing can. +func (c *Client) SandboxBackend(ctx context.Context) (string, error) { + return callMethod(ctx, c, api.SystemSandboxBackend, api.None{}) +} + +// SSHAgentKeys lists the keys the user's ssh-agent holds, for a sandboxed +// session that may use them. +func (c *Client) SSHAgentKeys(ctx context.Context) ([]string, error) { + return callMethod(ctx, c, api.SystemSSHAgentKeys, api.None{}) +} + +// FileListing is a checkout's files as the code tab lists them. +type FileListing = api.FileListing + +// Tree lists the files of the checkout at path. +func (c *Client) Tree(ctx context.Context, path string) (FileListing, error) { + return callMethod(ctx, c, api.ProjectTree, api.PathOptions{Path: path}) +} + +// SearchResult is a search of a checkout's files. +type SearchResult = api.SearchResult + +// SearchMatch is one line a search matched. +type SearchMatch = api.SearchMatch + +// Search finds query in the files of the checkout at path. +func (c *Client) Search(ctx context.Context, path, query string) (SearchResult, error) { + return callMethod(ctx, c, api.ProjectSearch, api.SearchOptions{Path: path, Query: query}) +} + +// ReadFile reads file rel of the checkout at path from the working tree. +func (c *Client) ReadFile(ctx context.Context, path, rel string) (string, error) { + return callMethod(ctx, c, api.ProjectReadFile, api.CheckoutFileOptions{Path: path, Rel: rel}) +} + +// ReadFileAt reads file rel of the checkout at path as it was at ref. +func (c *Client) ReadFileAt(ctx context.Context, path, rel, ref string) (string, error) { + return callMethod(ctx, c, api.ProjectReadFileAt, api.ReadFileAtOptions{Path: path, Rel: rel, Ref: ref}) +} + +// DiscardFile throws away every uncommitted change to file rel. +func (c *Client) DiscardFile(ctx context.Context, path, rel string) error { + _, err := callMethod(ctx, c, api.ProjectDiscardFile, api.CheckoutFileOptions{Path: path, Rel: rel}) + return err +} + +// RestoreLines undoes the revert undo describes, the result RevertLines gave. +func (c *Client) RestoreLines(ctx context.Context, path, rel string, undo RevertResult) error { + _, err := callMethod(ctx, c, api.ProjectRestoreLines, api.RestoreLinesOptions{Path: path, Rel: rel, Undo: undo}) + return err +} diff --git a/native/lichclient/projects.go b/native/backend/projects.go similarity index 61% rename from native/lichclient/projects.go rename to native/backend/projects.go index 3ef832862..a74f31d42 100644 --- a/native/lichclient/projects.go +++ b/native/backend/projects.go @@ -1,58 +1,54 @@ -package lichclient +package backend -import "context" +import ( + "context" -// RecentProject mirrors store.Recent: a closed project the reopen menu -// offers, and project.Project, what the picker and Relocate answer. -type RecentProject struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` -} + "github.com/lichdotdev/lich/api" +) + +type RecentProject = api.RecentProject // CloseProject marks a project closed, keeping its sessions for a reopen. func (c *Client) CloseProject(ctx context.Context, id string) error { - return c.Call(ctx, "store.CloseProject", nil, id) + _, err := callMethod(ctx, c, api.StoreCloseProject, api.ProjectIDOptions{ID: id}) + return err } // ReorderProjects stores the open projects' order; ids names every one. func (c *Client) ReorderProjects(ctx context.Context, ids []string) error { - return c.Call(ctx, "store.ReorderProjects", nil, ids) + _, err := callMethod(ctx, c, api.StoreReorderProjects, api.ReorderProjectsOptions{IDs: ids}) + return err } // RecentProjects returns the closed projects, the last one closed first. func (c *Client) RecentProjects(ctx context.Context) ([]RecentProject, error) { - var rows []RecentProject - return rows, c.Call(ctx, "store.RecentProjects", &rows, "") + return callMethod(ctx, c, api.StoreRecentProjects, api.SearchTermOptions{}) } // ClosedProjectCount counts every closed project, past RecentProjects' page. func (c *Client) ClosedProjectCount(ctx context.Context) (int, error) { - var n int - return n, c.Call(ctx, "store.ClosedProjectCount", &n, "") + return callMethod(ctx, c, api.StoreClosedProjectCount, api.SearchTermOptions{}) } // PickProject shows the backend's directory picker, nil when it is cancelled. func (c *Client) PickProject(ctx context.Context) (*RecentProject, error) { - var p *RecentProject - return p, c.Call(ctx, "project.Open", &p) + p, err := callMethod(ctx, c, api.ProjectOpen, api.None{}) + return (*RecentProject)(p), err } // RelocateProject asks the picker where project id went, keeping its id; nil // when cancelled. A directory another project holds is an error. func (c *Client) RelocateProject(ctx context.Context, id string) (*RecentProject, error) { - var p *RecentProject - return p, c.Call(ctx, "project.Relocate", &p, id) + p, err := callMethod(ctx, c, api.ProjectRelocate, api.ProjectIDOptions{ID: id}) + return (*RecentProject)(p), err } // PathExists reports whether a project directory is still there. func (c *Client) PathExists(ctx context.Context, path string) (bool, error) { - var ok bool - return ok, c.Call(ctx, "project.Exists", &ok, path) + return callMethod(ctx, c, api.ProjectExists, api.PathOptions{Path: path}) } // MissingPaths returns the paths among paths that are gone. func (c *Client) MissingPaths(ctx context.Context, paths []string) ([]string, error) { - var gone []string - return gone, c.Call(ctx, "project.Missing", &gone, paths) + return callMethod(ctx, c, api.ProjectMissing, api.PathsOptions{Paths: paths}) } diff --git a/native/backend/pulls.go b/native/backend/pulls.go new file mode 100644 index 000000000..0397321a5 --- /dev/null +++ b/native/backend/pulls.go @@ -0,0 +1,205 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type PRChanged = api.PRChanged + +type ChecksRollup = api.ChecksRollup + +type CheckItem = api.CheckItem + +type ThreadTally = api.ThreadTally + +type PRSummary = api.PRSummary + +type PRReviewer = api.PRReviewer + +type PRCommit = api.PRCommit + +type PRDetail = api.PRDetail + +type CommitPattern = api.CommitPattern + +type BranchRules = api.BranchRules + +// ListPullRequests reads the repository's pull requests in state +// (open|closed|merged|all) through gh, at most the backend's page of them. +func (c *Client) ListPullRequests(ctx context.Context, path, state string) ([]PRSummary, error) { + return callMethod(ctx, c, api.ProjectListPullRequests, api.ListPullRequestsOptions{Path: path, State: state}) +} + +// PullRequestDetail reads pull request number; 0 is the PR of path's branch, +// nil when it has none. +func (c *Client) PullRequestDetail(ctx context.Context, path string, number int) (*PRDetail, error) { + return callMethod(ctx, c, api.ProjectPullRequestDetail, api.NumberOptions{Path: path, Number: number}) +} + +// PullRequestConflicts works out which files conflict with base; it fetches +// both refs first. +func (c *Client) PullRequestConflicts(ctx context.Context, path string, number int, base, url string) ([]string, error) { + return callMethod(ctx, c, api.ProjectPullRequestConflicts, api.PRConflictsOptions{Path: path, Number: number, Base: base, PRURL: url}) +} + +func (c *Client) BranchRules(ctx context.Context, path, branch string) (*BranchRules, error) { + return callMethod(ctx, c, api.ProjectBranchRules, api.PathBranchOptions{Path: path, Branch: branch}) +} + +// MergePullRequest merges with method (squash|merge|rebase); an empty subject +// keeps gh's message, admin bypasses the base branch's rules. +func (c *Client) MergePullRequest(ctx context.Context, path string, number int, method, subject, body string, admin bool) error { + _, err := callMethod(ctx, c, api.ProjectMergePullRequest, api.MergePullRequestOptions{Path: path, Number: number, Method: method, Subject: subject, Body: body, Admin: admin}) + return err +} + +// CreatePullRequest opens GitHub's new pull request page for path's branch. +func (c *Client) CreatePullRequest(ctx context.Context, path string) error { + _, err := callMethod(ctx, c, api.ProjectCreatePullRequest, api.PathOptions{Path: path}) + return err +} + +// CreateWorktreeFromPR checks pull request number out into a new worktree. +func (c *Client) CreateWorktreeFromPR(ctx context.Context, projectPath, projectID string, number int) (*Worktree, error) { + return callMethod(ctx, c, api.ProjectCreateWorktreeFromPR, api.CreateWorktreeFromPROptions{ProjectPath: projectPath, ProjectID: projectID, Number: number}) +} + +// ListCheckouts is every checkout of path's repository holding a branch, the +// project's own directory included; Name is the branch. +func (c *Client) ListCheckouts(ctx context.Context, path string) ([]Worktree, error) { + return callMethod(ctx, c, api.ProjectListCheckouts, api.PathOptions{Path: path}) +} + +func (c *Client) WorktreeAdopted(ctx context.Context, path string) (bool, error) { + return callMethod(ctx, c, api.ProjectWorktreeAdopted, api.PathOptions{Path: path}) +} + +func (c *Client) WorktreeDirty(ctx context.Context, path string) (bool, error) { + return callMethod(ctx, c, api.ProjectWorktreeDirty, api.PathOptions{Path: path}) +} + +// RemoveWorktree removes the checkout at path; git refuses a dirty one +// without force, and an adopted one is refused without adoptedAck. +func (c *Client) RemoveWorktree(ctx context.Context, projectPath, path string, force, adoptedAck bool) error { + _, err := callMethod(ctx, c, api.ProjectRemoveWorktree, api.RemoveWorktreeOptions{ProjectPath: projectPath, WorktreePath: path, Force: force, AdoptedAck: adoptedAck}) + return err +} + +// PurgeWorktreeSessions drops every stored session rooted in path. +func (c *Client) PurgeWorktreeSessions(ctx context.Context, projectID, path string) error { + _, err := callMethod(ctx, c, api.StorePurgeWorktreeSessions, api.WorktreeSessionsOptions{ProjectID: projectID, Path: path}) + return err +} + +// AnnounceMerge tells the agents in checkout that its pull request merged. +func (c *Client) AnnounceMerge(ctx context.Context, checkout string, number int, title, branch, base string) error { + _, err := callMethod(ctx, c, api.RelayAnnounceMerge, api.AnnounceMergeOptions{ + Checkout: checkout, Number: number, Title: title, Branch: branch, Base: base, + }) + return err +} + +// Submit types message at session id's prompt and sends it; the session has to +// be a running agent. +func (c *Client) Submit(ctx context.Context, id, message string) error { + _, err := callMethod(ctx, c, api.RelaySubmit, api.SubmitOptions{SessionID: id, Message: message}) + return err +} + +type ReviewComment = api.ReviewComment + +// SubmitReview sends a review: event is approve|comment|request_changes, +// head the commit the comments were written against ("" lets GitHub assume +// its latest). +func (c *Client) SubmitReview(ctx context.Context, path string, number int, event, body, head string, comments []ReviewComment) error { + _, err := callMethod(ctx, c, api.ProjectSubmitReview, api.SubmitReviewOptions{Path: path, Number: number, Event: event, Body: body, Head: head, Comments: comments}) + return err +} + +// PullRequestDiff is the unified diff pull request number would merge, as +// GitHub computes it. +func (c *Client) PullRequestDiff(ctx context.Context, path string, number int) (string, error) { + return callMethod(ctx, c, api.ProjectPullRequestDiff, api.NumberOptions{Path: path, Number: number}) +} + +// PullRequestMergeBase is the commit pull request number's diff is taken +// from: the old side of every file in it. +func (c *Client) PullRequestMergeBase(ctx context.Context, path string, number int) (string, error) { + return callMethod(ctx, c, api.ProjectPullRequestMergeBase, api.NumberOptions{Path: path, Number: number}) +} + +type PRConversation = api.PRConversation + +type PRReview = api.PRReview + +type PRComment = api.PRComment + +type PRReviewThread = api.PRReviewThread + +type PRThreadComment = api.PRThreadComment + +func (c *Client) PullRequestConversation(ctx context.Context, path string, number int) (*PRConversation, error) { + return callMethod(ctx, c, api.ProjectPullRequestConversation, api.NumberOptions{Path: path, Number: number}) +} + +// ReplyToReviewThread answers the thread comment commentID belongs to. +func (c *Client) ReplyToReviewThread(ctx context.Context, path string, number int, commentID int64, body string) error { + _, err := callMethod(ctx, c, api.ProjectReplyToReviewThread, api.PRCommentBodyOptions{Path: path, Number: number, CommentID: commentID, Body: body}) + return err +} + +// EditReviewComment rewrites thread comment commentID. +func (c *Client) EditReviewComment(ctx context.Context, path string, number int, commentID int64, body string) error { + _, err := callMethod(ctx, c, api.ProjectEditReviewComment, api.PRCommentBodyOptions{Path: path, Number: number, CommentID: commentID, Body: body}) + return err +} + +// DeleteReviewComment removes thread comment commentID. +func (c *Client) DeleteReviewComment(ctx context.Context, path string, number int, commentID int64) error { + _, err := callMethod(ctx, c, api.ProjectDeleteReviewComment, api.PRCommentOptions{Path: path, Number: number, CommentID: commentID}) + return err +} + +// EditReviewBody rewrites the summary of review reviewID (its database id). +func (c *Client) EditReviewBody(ctx context.Context, path string, number int, reviewID int64, body string) error { + _, err := callMethod(ctx, c, api.ProjectEditReviewBody, api.PRReviewBodyOptions{Path: path, Number: number, ReviewID: reviewID, Body: body}) + return err +} + +// ResolveReviewThread resolves thread threadID, or reopens it. +func (c *Client) ResolveReviewThread(ctx context.Context, path, threadID string, resolved bool) error { + _, err := callMethod(ctx, c, api.ProjectResolveReviewThread, api.ResolveThreadOptions{Path: path, ThreadID: threadID, Resolved: resolved}) + return err +} + +// CommentOnPullRequest comments on the pull request itself. +func (c *Client) CommentOnPullRequest(ctx context.Context, path string, number int, body string) error { + _, err := callMethod(ctx, c, api.ProjectCommentOnPullRequest, api.PRBodyOptions{Path: path, Number: number, Body: body}) + return err +} + +// EditPullRequestComment rewrites pull request comment commentID. +func (c *Client) EditPullRequestComment(ctx context.Context, path string, number int, commentID int64, body string) error { + _, err := callMethod(ctx, c, api.ProjectEditPullRequestComment, api.PRCommentBodyOptions{Path: path, Number: number, CommentID: commentID, Body: body}) + return err +} + +// DeletePullRequestComment removes pull request comment commentID. +func (c *Client) DeletePullRequestComment(ctx context.Context, path string, number int, commentID int64) error { + _, err := callMethod(ctx, c, api.ProjectDeletePullRequestComment, api.PRCommentOptions{Path: path, Number: number, CommentID: commentID}) + return err +} + +// EditPullRequestTitle renames the pull request. +func (c *Client) EditPullRequestTitle(ctx context.Context, path string, number int, title string) error { + _, err := callMethod(ctx, c, api.ProjectEditPullRequestTitle, api.PRTitleOptions{Path: path, Number: number, Title: title}) + return err +} + +// EditPullRequestBody rewrites the pull request's description; empty clears it. +func (c *Client) EditPullRequestBody(ctx context.Context, path string, number int, body string) error { + _, err := callMethod(ctx, c, api.ProjectEditPullRequestBody, api.PRBodyOptions{Path: path, Number: number, Body: body}) + return err +} diff --git a/native/lichclient/session.go b/native/backend/session.go similarity index 62% rename from native/lichclient/session.go rename to native/backend/session.go index d6e14550e..86f266c03 100644 --- a/native/lichclient/session.go +++ b/native/backend/session.go @@ -1,58 +1,65 @@ -package lichclient +package backend -import "context" +import ( + "context" + + "github.com/lichdotdev/lich/api" +) // CloseSession parks a session: its row leaves the open list but stays for a // later reopen, and activeID becomes the project's active session ("" for // none). It does not stop the session's process; CloseTerminal does. func (c *Client) CloseSession(ctx context.Context, projectID, sessionID, activeID string) error { - return c.Call(ctx, "store.CloseSession", nil, projectID, sessionID, activeID) + _, err := callMethod(ctx, c, api.StoreCloseSession, api.ParkSessionOptions{ProjectID: projectID, SessionID: sessionID, ActiveID: activeID}) + return err } // CloseTerminal stops a session's process and drops its PTY. func (c *Client) CloseTerminal(ctx context.Context, id string) error { - return c.Call(ctx, "terminal.Close", nil, id) + _, err := callMethod(ctx, c, api.TerminalClose, api.SessionIDOptions{ID: id}) + return err } // SetSessionPinned pins a session to the head of the sidebar, or unpins it. func (c *Client) SetSessionPinned(ctx context.Context, sessionID string, pinned bool) error { - return c.Call(ctx, "store.SetSessionPinned", nil, sessionID, pinned) + _, err := callMethod(ctx, c, api.StoreSetSessionPinned, api.SessionPinnedOptions{SessionID: sessionID, Pinned: pinned}) + return err } // SetSessionUnread records whether a session's finished turn is still unread, // the one mark a window restart brings back (Session.Unread). func (c *Client) SetSessionUnread(ctx context.Context, sessionID string, unread bool) error { - return c.Call(ctx, "store.SetSessionUnread", nil, sessionID, unread) + _, err := callMethod(ctx, c, api.StoreSetSessionUnread, api.SessionUnreadOptions{SessionID: sessionID, Unread: unread}) + return err } // RenameSession gives a session the label the user typed. func (c *Client) RenameSession(ctx context.Context, sessionID, label string) error { - return c.Call(ctx, "store.RenameSession", nil, sessionID, label) + _, err := callMethod(ctx, c, api.StoreRenameSession, api.LabelSessionOptions{SessionID: sessionID, Label: label}) + return err } // SetSessionColor tints a session's card, "" for the theme's. func (c *Client) SetSessionColor(ctx context.Context, sessionID, color string) error { - return c.Call(ctx, "store.SetSessionColor", nil, sessionID, color) + _, err := callMethod(ctx, c, api.StoreSetSessionColor, api.SessionColorOptions{SessionID: sessionID, Color: color}) + return err } // Live reports whether a session has a process running right now. func (c *Client) Live(ctx context.Context, id string) (bool, error) { - var live bool - return live, c.Call(ctx, "terminal.Live", &live, id) + return callMethod(ctx, c, api.TerminalLive, api.SessionIDOptions{ID: id}) } // WorkdirMissing reports whether a session cannot start in cwd because the // directory is provably gone; false for every uncertainty. func (c *Client) WorkdirMissing(ctx context.Context, cwd string) (bool, error) { - var missing bool - return missing, c.Call(ctx, "terminal.WorkdirMissing", &missing, cwd) + return callMethod(ctx, c, api.TerminalWorkdirMissing, api.WorkdirOptions{Cwd: cwd}) } // ResumeAvailable reports whether the provider conversation providerSessionID // can still be reopened from cwd. func (c *Client) ResumeAvailable(ctx context.Context, kind, providerSessionID, cwd string) (bool, error) { - var available bool - return available, c.Call(ctx, "terminal.ResumeAvailable", &available, kind, providerSessionID, cwd) + return callMethod(ctx, c, api.TerminalResumeAvailable, api.ResumeAvailableOptions{Kind: kind, ProviderSessionID: providerSessionID, Cwd: cwd}) } // ReopenSession brings a parked session back to its project's open list @@ -60,6 +67,5 @@ func (c *Client) ResumeAvailable(ctx context.Context, kind, providerSessionID, c // prompt follows. It returns nil when no parked session has sessionID: another // window reopened it, or its worktree was removed. func (c *Client) ReopenSession(ctx context.Context, sessionID, newSessionID string) (*Session, error) { - var s *Session - return s, c.Call(ctx, "store.ReopenSession", &s, sessionID, newSessionID) + return callMethod(ctx, c, api.StoreReopenSession, api.ReopenSessionOptions{SessionID: sessionID, NewSessionID: newSessionID}) } diff --git a/native/lichclient/store.go b/native/backend/store.go similarity index 59% rename from native/lichclient/store.go rename to native/backend/store.go index 95db36877..2993732ab 100644 --- a/native/lichclient/store.go +++ b/native/backend/store.go @@ -1,61 +1,57 @@ -package lichclient - -import "context" - -// DetectedProvider mirrors providers.Detected (internal/providers), trimmed to -// what the launch menu and Settings › Providers read. Binary is the executable -// a spawn asks $PATH for, Source where it was found ("path", "setting", or "" -// for none) and Docs the page that documents installing it. -type DetectedProvider struct { - ID string `json:"id"` - Name string `json:"name"` - Binary string `json:"binary"` - Installed bool `json:"installed"` - Source string `json:"source"` - Docs string `json:"docs"` -} +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type DetectedProvider = api.DetectedProvider // ReorderSessions stores a project's full card order. The backend writes a // position per id, so ids must name every session of the project. func (c *Client) ReorderSessions(ctx context.Context, projectID string, ids []string) error { - return c.Call(ctx, "store.ReorderSessions", nil, projectID, ids) + _, err := callMethod(ctx, c, api.StoreReorderSessions, api.ReorderSessionsOptions{ProjectID: projectID, IDs: ids}) + return err } // SetSessionFolder files a session under folder, "" to take it out of one. func (c *Client) SetSessionFolder(ctx context.Context, sessionID, folder string) error { - return c.Call(ctx, "store.SetSessionFolder", nil, sessionID, folder) + _, err := callMethod(ctx, c, api.StoreSetSessionFolder, api.SessionFolderOptions{SessionID: sessionID, Folder: folder}) + return err } // RenameFolder renames a project's folder on every session filed under it, // parked ones included, and ungroups it when to is "". It fails for an empty // from, and returns the ids it rewrote. func (c *Client) RenameFolder(ctx context.Context, projectID, from, to string) ([]string, error) { - var moved []string - return moved, c.Call(ctx, "store.RenameFolder", &moved, projectID, from, to) + return callMethod(ctx, c, api.StoreRenameFolder, api.RenameProjectFolderOptions{ProjectID: projectID, From: from, To: to}) } // AddSessionFrom adds a session opened from the session originID, which the // row remembers along with originLabel, what it was called then. func (c *Client) AddSessionFrom(ctx context.Context, projectID, sessionID, label, kind, path string, nextSeq int, originID, originLabel, folder string) error { - return c.Call(ctx, "store.AddSessionFrom", nil, projectID, sessionID, label, kind, path, nextSeq, originID, originLabel, folder) + _, err := callMethod(ctx, c, api.StoreAddSessionFrom, api.AddSessionFromOptions{ProjectID: projectID, SessionID: sessionID, Label: label, Kind: kind, Path: path, NextSeq: nextSeq, OriginID: originID, OriginLabel: originLabel, Folder: folder}) + return err } // MarkForked records that the session was born branching its origin's // conversation. func (c *Client) MarkForked(ctx context.Context, sessionID string) error { - return c.Call(ctx, "store.MarkForked", nil, sessionID) + _, err := callMethod(ctx, c, api.StoreMarkForked, api.SessionIDOptions{ID: sessionID}) + return err } // AddSessionInFolder is AddSession for a session opened into folder, written // with the row. The sandbox answer is always "": the native window has no // confinement step, so the provider's rung decides. func (c *Client) AddSessionInFolder(ctx context.Context, projectID, sessionID, label, kind, path string, nextSeq int, folder string) error { - return c.Call(ctx, "store.AddSessionInFolder", nil, projectID, sessionID, label, kind, path, nextSeq, "", folder) + _, err := callMethod(ctx, c, api.StoreAddSessionInFolder, api.AddSessionInFolderOptions{AddSessionOptions: api.AddSessionOptions{ProjectID: projectID, SessionID: sessionID, Label: label, Kind: kind, Path: path, NextSeq: nextSeq}, Folder: folder}) + return err } // DetectProviders probes which provider CLIs this machine has, nil when the // backend answers none. func (c *Client) DetectProviders(ctx context.Context) ([]DetectedProvider, error) { - var found []DetectedProvider - return found, c.Call(ctx, "providers.Detect", &found) + return callMethod(ctx, c, api.ProvidersDetect, api.None{}) } diff --git a/native/lichclient/store_test.go b/native/backend/store_test.go similarity index 65% rename from native/lichclient/store_test.go rename to native/backend/store_test.go index 92024ad26..86dc043a3 100644 --- a/native/lichclient/store_test.go +++ b/native/backend/store_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" @@ -8,49 +8,49 @@ import ( "testing" ) -func TestStoreCallsSendPositionalArgs(t *testing.T) { +func TestStoreCallsSendTheirOptions(t *testing.T) { ctx := context.Background() cases := []struct { name, method, args string result any call func(c *Client) error }{ - {"ReorderSessions", "store.ReorderSessions", `"p",["b","a"]`, nil, func(c *Client) error { + {"ReorderSessions", "store.ReorderSessions", `{"projectId":"p","ids":["b","a"]}`, nil, func(c *Client) error { return c.ReorderSessions(ctx, "p", []string{"b", "a"}) }}, - {"SetSessionFolder", "store.SetSessionFolder", `"s1","Apps"`, nil, func(c *Client) error { + {"SetSessionFolder", "store.SetSessionFolder", `{"sessionId":"s1","folder":"Apps"}`, nil, func(c *Client) error { return c.SetSessionFolder(ctx, "s1", "Apps") }}, - {"RenameFolder", "store.RenameFolder", `"p","Apps","Ops"`, []string{"s1"}, func(c *Client) error { + {"RenameFolder", "store.RenameFolder", `{"projectId":"p","from":"Apps","to":"Ops"}`, []string{"s1"}, func(c *Client) error { _, err := c.RenameFolder(ctx, "p", "Apps", "Ops") return err }}, - {"AddSessionInFolder", "store.AddSessionInFolder", `"p","s9","Claude 9","claude","/wt/a",9,"","Apps"`, nil, func(c *Client) error { + {"AddSessionInFolder", "store.AddSessionInFolder", `{"projectId":"p","sessionId":"s9","label":"Claude 9","kind":"claude","path":"/wt/a","nextSeq":9,"sandbox":"","folder":"Apps"}`, nil, func(c *Client) error { return c.AddSessionInFolder(ctx, "p", "s9", "Claude 9", "claude", "/wt/a", 9, "Apps") }}, {"DetectProviders", "providers.Detect", ``, []DetectedProvider{}, func(c *Client) error { _, err := c.DetectProviders(ctx) return err }}, - {"ProviderVersion", "providers.Version", `"codex"`, "0.162.1", func(c *Client) error { + {"ProviderVersion", "providers.Version", `{"bin":"codex"}`, "0.162.1", func(c *Client) error { _, err := c.ProviderVersion(ctx, "codex") return err }}, - {"ProviderBinaries", "providers.OnPath", `"codex"`, []ProviderBinary{{Path: "/usr/bin/codex", Version: "0.147.0"}}, func(c *Client) error { + {"ProviderBinaries", "providers.OnPath", `{"id":"codex"}`, []ProviderBinary{{Path: "/usr/bin/codex", Version: "0.147.0"}}, func(c *Client) error { _, err := c.ProviderBinaries(ctx, "codex") return err }}, - {"CloseProject", "store.CloseProject", `"p"`, nil, func(c *Client) error { + {"CloseProject", "store.CloseProject", `{"id":"p"}`, nil, func(c *Client) error { return c.CloseProject(ctx, "p") }}, - {"ReorderProjects", "store.ReorderProjects", `["b","a"]`, nil, func(c *Client) error { + {"ReorderProjects", "store.ReorderProjects", `{"ids":["b","a"]}`, nil, func(c *Client) error { return c.ReorderProjects(ctx, []string{"b", "a"}) }}, - {"RecentProjects", "store.RecentProjects", `""`, []RecentProject{}, func(c *Client) error { + {"RecentProjects", "store.RecentProjects", `{"term":""}`, []RecentProject{}, func(c *Client) error { _, err := c.RecentProjects(ctx) return err }}, - {"ClosedProjectCount", "store.ClosedProjectCount", `""`, 3, func(c *Client) error { + {"ClosedProjectCount", "store.ClosedProjectCount", `{"term":""}`, 3, func(c *Client) error { _, err := c.ClosedProjectCount(ctx) return err }}, @@ -58,15 +58,15 @@ func TestStoreCallsSendPositionalArgs(t *testing.T) { _, err := c.PickProject(ctx) return err }}, - {"RelocateProject", "project.Relocate", `"p"`, nil, func(c *Client) error { + {"RelocateProject", "project.Relocate", `{"id":"p"}`, nil, func(c *Client) error { _, err := c.RelocateProject(ctx, "p") return err }}, - {"PathExists", "project.Exists", `"/a"`, true, func(c *Client) error { + {"PathExists", "project.Exists", `{"path":"/a"}`, true, func(c *Client) error { _, err := c.PathExists(ctx, "/a") return err }}, - {"MissingPaths", "project.Missing", `["/a","/b"]`, []string{"/b"}, func(c *Client) error { + {"MissingPaths", "project.Missing", `{"paths":["/a","/b"]}`, []string{"/b"}, func(c *Client) error { _, err := c.MissingPaths(ctx, []string{"/a", "/b"}) return err }}, @@ -112,7 +112,7 @@ func TestDetectedProviderReadsBinarySourceAndDocs(t *testing.T) { if err := json.Unmarshal([]byte(wire), &d); err != nil { t.Fatal(err) } - want := DetectedProvider{ID: "codex", Name: "Codex", Binary: "codex", Installed: true, Source: "setting", Docs: "https://example.com/codex"} + want := DetectedProvider{ID: "codex", Name: "Codex", Binary: "codex", Installed: true, Path: "/opt/codex", Source: "setting", Docs: "https://example.com/codex"} if d != want { t.Errorf("decoded %+v, want %+v", d, want) } diff --git a/native/backend/themes.go b/native/backend/themes.go new file mode 100644 index 000000000..c3734f987 --- /dev/null +++ b/native/backend/themes.go @@ -0,0 +1,85 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +// The theme shapes are api's, the backend's own definition. +type ( + Theme = api.Theme + ThemeSelection = api.ThemeSelection + SyntaxStyle = api.SyntaxStyle + ThemeSource = api.ThemeSource + BrokenTheme = api.BrokenTheme + ThemeImportResult = api.ThemeImportResult + ThemeGitResult = api.ThemeGitResult +) + +// ThemeSelection reads the stored selection; a theme never chosen reads +// "system". +func (c *Client) ThemeSelection(ctx context.Context) (ThemeSelection, error) { + return callMethod(ctx, c, api.ThemesSelection, api.None{}) +} + +// SelectTheme stores id, "system" or an installed theme, as the selection. +func (c *Client) SelectTheme(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.ThemesSelect, api.SelectThemeOptions{ID: id}) + return err +} + +// SetFollowSystem stores whether the window draws the selected theme's pair +// while the OS asks for the pair's scheme. +func (c *Client) SetFollowSystem(ctx context.Context, on bool) error { + _, err := callMethod(ctx, c, api.ThemesSetFollowSystem, api.FollowSystemOptions{On: on}) + return err +} + +// Themes returns the bundled themes and the imported ones that load. +func (c *Client) Themes(ctx context.Context) ([]Theme, error) { + return callMethod(ctx, c, api.ThemesList, api.None{}) +} + +// BrokenThemes returns the stored themes Themes leaves out, with why. +func (c *Client) BrokenThemes(ctx context.Context) ([]BrokenTheme, error) { + return callMethod(ctx, c, api.ThemesListBroken, api.None{}) +} + +// ImportTheme imports the theme file at path. +func (c *Client) ImportTheme(ctx context.Context, path string, overwrite bool) (ThemeImportResult, error) { + return callMethod(ctx, c, api.ThemesImport, api.ImportThemeOptions{Path: path, Overwrite: overwrite}) +} + +// InstallThemes clones url and installs every theme its manifest lists. +func (c *Client) InstallThemes(ctx context.Context, url string, overwrite bool) (ThemeGitResult, error) { + return callMethod(ctx, c, api.ThemesInstallFromGit, api.InstallThemesOptions{URL: url, Overwrite: overwrite}) +} + +// UpdateTheme re-clones the repository theme id came from. +func (c *Client) UpdateTheme(ctx context.Context, id string) (ThemeGitResult, error) { + return callMethod(ctx, c, api.ThemesUpdateFromGit, api.UpdateThemeOptions{ID: id}) +} + +// RemoveTheme deletes an imported theme; the backend refuses a bundled one. +func (c *Client) RemoveTheme(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.ThemesRemove, api.RemoveThemeOptions{ID: id}) + return err +} + +// SaveThemeTemplate writes the starter theme to path. +func (c *Client) SaveThemeTemplate(ctx context.Context, path string) error { + _, err := callMethod(ctx, c, api.ThemesSaveTemplate, api.SaveThemeTemplateOptions{Path: path}) + return err +} + +// PickFile opens the backend's file picker; "" is a cancelled one. +func (c *Client) PickFile(ctx context.Context, title string) (string, error) { + return callMethod(ctx, c, api.ProjectPickFile, api.PickFileOptions{Title: title}) +} + +// PickSaveFile opens the backend's save picker on defaultName; "" is a +// cancelled one. +func (c *Client) PickSaveFile(ctx context.Context, title, defaultName string) (string, error) { + return callMethod(ctx, c, api.ProjectPickSaveFile, api.PickSaveFileOptions{Title: title, DefaultName: defaultName}) +} diff --git a/native/backend/turnstates.go b/native/backend/turnstates.go new file mode 100644 index 000000000..eb3e21c62 --- /dev/null +++ b/native/backend/turnstates.go @@ -0,0 +1,15 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type TurnState = api.TurnState + +// TurnStates is what each live session last reported, the catch-up for the +// events /events dropped while no window was attached. +func (c *Client) TurnStates(ctx context.Context) ([]TurnState, error) { + return callMethod(ctx, c, api.TerminalTurnStates, api.None{}) +} diff --git a/native/lichclient/turnstates_test.go b/native/backend/turnstates_test.go similarity index 81% rename from native/lichclient/turnstates_test.go rename to native/backend/turnstates_test.go index e7456de31..9175a3e5f 100644 --- a/native/lichclient/turnstates_test.go +++ b/native/backend/turnstates_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" @@ -24,13 +24,13 @@ func TestTurnStatesDecodesTheSnapshot(t *testing.T) { if len(got) != 3 { t.Fatalf("got %+v, want 3 sessions", got) } - if s := got[0]; s.ID != "s1" || s.State != "busy" || s.Tool != "Bash" || s.Detail != "go test" || s.Since != 1000 || len(s.Usage) == 0 { + if s := got[0]; s.ID != "s1" || s.State != "busy" || s.Tool != "Bash" || s.Detail != "go test" || s.Since != 1000 || s.Usage == nil || s.Usage.Percent != 42 { t.Errorf("s1 = %+v", s) } if s := got[1]; s.State != "waiting" || s.Reason != "permission" || s.Usage != nil { t.Errorf("s2 = %+v", s) } - if s := got[2]; s.State != "" || len(s.Usage) == 0 || s.Cwd != "/elsewhere" || s.Host != "tmux" { + if s := got[2]; s.State != "" || s.Usage == nil || s.Usage.Percent != 7 || s.Cwd != "/elsewhere" || s.Host != "tmux" { t.Errorf("s3 = %+v", s) } } diff --git a/native/backend/update.go b/native/backend/update.go new file mode 100644 index 000000000..182db2206 --- /dev/null +++ b/native/backend/update.go @@ -0,0 +1,52 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type AppUpdateStatus = api.AppUpdateStatus + +type PluginStatus = api.PluginStatus + +type PluginProfiles = api.PluginProfiles + +type PluginProfileFailure = api.PluginProfileFailure + +type PatchNotesGroup = api.PatchNotesGroup + +type PatchNotesHighlight = api.PatchNotesHighlight + +type PatchNotes = api.PatchNotes + +// AppUpdateStatus looks up the latest release. +func (c *Client) AppUpdateStatus(ctx context.Context) (AppUpdateStatus, error) { + return callMethod(ctx, c, api.AppUpdateGetStatus, api.None{}) +} + +// PatchNotes returns the running build's changelog section in locale, the +// interface language's tag; the backend falls back to English when the release +// has no translation into it. +func (c *Client) PatchNotes(ctx context.Context, locale string) (PatchNotes, error) { + return callMethod(ctx, c, api.PatchNotesCurrent, api.PatchNotesOptions{Locale: locale}) +} + +// PluginStatuses lists the lich plugin's state in every provider that can run +// it, in display order. +func (c *Client) PluginStatuses(ctx context.Context) ([]PluginStatus, error) { + return callMethod(ctx, c, api.AgentPluginStatus, api.None{}) +} + +// InstallPlugin installs the lich plugin into provider's CLI. +func (c *Client) InstallPlugin(ctx context.Context, provider string) error { + _, err := callMethod(ctx, c, api.AgentPluginInstall, api.PluginProviderOptions{Provider: provider}) + return err +} + +// UpdatePlugin moves provider's installed lich plugin to the release this lich +// is compatible with. +func (c *Client) UpdatePlugin(ctx context.Context, provider string) error { + _, err := callMethod(ctx, c, api.AgentPluginUpdate, api.PluginProviderOptions{Provider: provider}) + return err +} diff --git a/native/lichclient/update_test.go b/native/backend/update_test.go similarity index 89% rename from native/lichclient/update_test.go rename to native/backend/update_test.go index 61f68db22..3c174e7ba 100644 --- a/native/lichclient/update_test.go +++ b/native/backend/update_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" @@ -16,7 +16,7 @@ func TestPatchNotesKeepsNoSectionApartFromAnEmptyOne(t *testing.T) { "empty section": {`{"version":"1.0.0","highlights":null,"groups":[]}`, true}, } { c := fakeBackend(t, func(method string, args []json.RawMessage) (any, int) { - if method != "patchnotes.Current" || len(args) != 1 || string(args[0]) != `"pt-BR"` { + if method != "patchnotes.Current" || len(args) != 1 || string(args[0]) != `{"locale":"pt-BR"}` { t.Errorf("call %s %s, want patchnotes.Current with the locale", method, args) } return json.RawMessage(tc.body), http.StatusOK @@ -41,7 +41,7 @@ func TestInstallAndUpdatePluginNameTheProvider(t *testing.T) { if err := c.UpdatePlugin(ctx, "codex"); err != nil { t.Fatal(err) } - want := []string{`agentplugin.Install "claude"`, `agentplugin.Update "codex"`} + want := []string{`agentplugin.Install {"provider":"claude"}`, `agentplugin.Update {"provider":"codex"}`} if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] { t.Fatalf("calls %v, want %v", calls, want) } diff --git a/native/backend/vcs.go b/native/backend/vcs.go new file mode 100644 index 000000000..eb54a014e --- /dev/null +++ b/native/backend/vcs.go @@ -0,0 +1,57 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +const ( + CheckOK = api.CheckOK + CheckEmpty = api.CheckEmpty +) + +type BinaryCheck = api.BinaryCheck + +type CommitIdentity = api.CommitIdentity + +// Verify resolves bin as a session's spawn would, through the pinned $PATH. +func (c *Client) Verify(ctx context.Context, bin string) (BinaryCheck, error) { + return callMethod(ctx, c, api.ProvidersVerify, api.BinaryOptions{Bin: bin}) +} + +type ProviderBinary = api.ProviderBinary + +// ProviderVersion runs bin, resolved as Verify resolves it, with --version. +// It boots the whole CLI, so it is asked off the frame. +func (c *Client) ProviderVersion(ctx context.Context, bin string) (string, error) { + return callMethod(ctx, c, api.ProvidersVersion, api.BinaryOptions{Bin: bin}) +} + +// ProviderBinaries lists every distinct binary of provider id on the pinned +// $PATH, in $PATH order, each with its version. +func (c *Client) ProviderBinaries(ctx context.Context, id string) ([]ProviderBinary, error) { + return callMethod(ctx, c, api.ProvidersOnPath, api.ProviderIDOptions{ID: id}) +} + +// RefreshPath re-reads the login shell's $PATH and re-pins it, so the next +// Verify answers from what is installed now. +func (c *Client) RefreshPath(ctx context.Context) error { + _, err := callMethod(ctx, c, api.ProvidersRefreshPath, api.None{}) + return err +} + +// GitHubAccounts lists gh's signed-in accounts as "/"; empty +// when gh is signed in to none. A round trip to every host gh knows. +func (c *Client) GitHubAccounts(ctx context.Context) ([]string, error) { + accounts, err := callMethod(ctx, c, api.ProjectGitHubAccounts, api.None{}) + if accounts == nil { + accounts = []string{} + } + return accounts, err +} + +// CommitIdentity returns who commits in the checkout at path land as. +func (c *Client) CommitIdentity(ctx context.Context, path string) (CommitIdentity, error) { + return callMethod(ctx, c, api.ProjectCommitIdentity, api.PathOptions{Path: path}) +} diff --git a/native/lichclient/vcs_test.go b/native/backend/vcs_test.go similarity index 97% rename from native/lichclient/vcs_test.go rename to native/backend/vcs_test.go index ac21f3dfa..8277decba 100644 --- a/native/lichclient/vcs_test.go +++ b/native/backend/vcs_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/window.go b/native/backend/window.go similarity index 60% rename from native/lichclient/window.go rename to native/backend/window.go index 3782c969e..8d33280f1 100644 --- a/native/lichclient/window.go +++ b/native/backend/window.go @@ -1,50 +1,48 @@ -package lichclient +package backend -import "context" +import ( + "context" + + "github.com/lichdotdev/lich/api" +) // LiveCount is how many sessions have a process running right now. func (c *Client) LiveCount(ctx context.Context) (int, error) { - var n int - return n, c.Call(ctx, "terminal.LiveCount", &n) + return callMethod(ctx, c, api.TerminalLiveCount, api.None{}) } // Quit ends lich: every session's process ends with it. The reply can be // lost to the exit it announces. func (c *Client) Quit(ctx context.Context) error { - return c.Call(ctx, "system.Quit", nil, struct{}{}) + _, err := callMethod(ctx, c, api.SystemQuit, api.QuitOptions{}) + return err } // AttachNative makes this window lich's window: the tray and a second launch // bring it forward, or start executable again once it closed. func (c *Client) AttachNative(ctx context.Context, executable string) error { - return c.Call(ctx, "system.AttachNative", nil, struct { - Executable string `json:"executable"` - }{executable}) + _, err := callMethod(ctx, c, api.SystemAttachNative, api.AttachNativeOptions{Executable: executable}) + return err } -// TrayLabels are the tray menu's words; Running carries a {count} placeholder. -type TrayLabels struct { - Show string `json:"show"` - Running string `json:"running"` - Quit string `json:"quit"` -} +type TrayLabels = api.TrayLabels // SetTrayLabels gives the tray its words; the first call puts it up. func (c *Client) SetTrayLabels(ctx context.Context, labels TrayLabels) error { - return c.Call(ctx, "tray.SetLabels", nil, labels) + _, err := callMethod(ctx, c, api.TraySetLabels, labels) + return err } // Notify raises a desktop notification: summary is the headline, detail the // second line ("" omits it). func (c *Client) Notify(ctx context.Context, summary, detail string) error { - return c.Call(ctx, "system.Notify", nil, summary, detail) + _, err := callMethod(ctx, c, api.SystemNotify, api.NotifyOptions{Summary: summary, Detail: detail}) + return err } // LastTurnSaid is the last thing the agent said in session id's conversation, // "" when its transcript has none to read. func (c *Client) LastTurnSaid(ctx context.Context, id string) (string, error) { - var said struct { - Text string `json:"text"` - } - return said.Text, c.Call(ctx, "terminal.LastTurnSaid", &said, id) + said, err := callMethod(ctx, c, api.TerminalLastTurnSaid, api.SessionIDOptions{ID: id}) + return said.Text, err } diff --git a/native/backend/worktree.go b/native/backend/worktree.go new file mode 100644 index 000000000..ce8cacfb2 --- /dev/null +++ b/native/backend/worktree.go @@ -0,0 +1,60 @@ +package backend + +import ( + "context" + + "github.com/lichdotdev/lich/api" +) + +type Worktree = api.Worktree + +type Branches = api.Branches + +type BaseFreshness = api.BaseFreshness + +type Issue = api.Issue + +func (c *Client) ListBranches(ctx context.Context, path string) (Branches, error) { + return callMethod(ctx, c, api.ProjectListBranches, api.PathOptions{Path: path}) +} + +// BaseFreshness fetches branch's upstream first, so it waits on the network +// for up to the backend's fetch budget. +func (c *Client) BaseFreshness(ctx context.Context, path, branch string) (BaseFreshness, error) { + return callMethod(ctx, c, api.ProjectBaseFreshness, api.PathBranchOptions{Path: path, Branch: branch}) +} + +// CreateWorktree checks a new branch name out off base; a remote base is +// fetched first. +func (c *Client) CreateWorktree(ctx context.Context, projectPath, projectID, name, base string, baseIsRemote bool) (*Worktree, error) { + return callMethod(ctx, c, api.ProjectCreateWorktree, api.CreateWorktreeOptions{ProjectPath: projectPath, ProjectID: projectID, Name: name, Base: base, BaseIsRemote: baseIsRemote}) +} + +// CarryUncommitted copies src's uncommitted work onto dst. +func (c *Client) CarryUncommitted(ctx context.Context, src, dst string) error { + _, err := callMethod(ctx, c, api.ProjectCarryUncommitted, api.CarryUncommittedOptions{Src: src, Dst: dst}) + return err +} + +// Issue resolves a GitHub issue through gh. +func (c *Client) Issue(ctx context.Context, path string, number int) (Issue, error) { + return callMethod(ctx, c, api.ProjectIssue, api.NumberOptions{Path: path, Number: number}) +} + +// ReopenWorktreeSession brings back the session parked on path under +// newSessionID, nil when none is parked there. +func (c *Client) ReopenWorktreeSession(ctx context.Context, projectID, path, newSessionID string) (*Session, error) { + return callMethod(ctx, c, api.StoreReopenWorktreeSession, api.ReopenWorktreeSessionOptions{ProjectID: projectID, Path: path, NewSessionID: newSessionID}) +} + +// AddSessionWithSandbox is AddSession with the sandbox answer the session was +// opened with: "on", "off", or "" to leave it to the provider's rung. +func (c *Client) AddSessionWithSandbox(ctx context.Context, projectID, sessionID, label, kind, path string, nextSeq int, sandbox string) error { + _, err := callMethod(ctx, c, api.StoreAddSession, api.AddSessionOptions{ProjectID: projectID, SessionID: sessionID, Label: label, Kind: kind, Path: path, NextSeq: nextSeq, Sandbox: sandbox}) + return err +} + +// Ready reports whether session id sits at a prompt that can take input. +func (c *Client) Ready(ctx context.Context, id string) (bool, error) { + return callMethod(ctx, c, api.TerminalReady, api.SessionIDOptions{ID: id}) +} diff --git a/native/cmd/lich-native/app_update_gate.go b/native/cmd/lich-native/app_update_gate.go index 747c8c4e0..bdb5e89de 100644 --- a/native/cmd/lich-native/app_update_gate.go +++ b/native/cmd/lich-native/app_update_gate.go @@ -6,8 +6,8 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -29,12 +29,12 @@ type appUpdateGate struct { // applying is the version ApplyUpdate is installing, "" when idle; last // is its latest step, nil before the first. applying string - last *lichclient.AppUpdateProgress + last *backend.AppUpdateProgress } // shouldPromptUpdate is decideUpdateAction: a newer release Later was not // clicked for. -func shouldPromptUpdate(st lichclient.AppUpdateStatus, dismissed string) bool { +func shouldPromptUpdate(st backend.AppUpdateStatus, dismissed string) bool { return st.UpdateAvailable && dismissed != st.LatestVersion } @@ -75,7 +75,7 @@ func (u *window) checkAppUpdate() { } // promptAppUpdate raises st's prompt, once per release this run. -func (u *window) promptAppUpdate(st lichclient.AppUpdateStatus) { +func (u *window) promptAppUpdate(st backend.AppUpdateStatus) { g := &u.appUpdate g.mu.Lock() g.prompted = st.LatestVersion @@ -85,7 +85,7 @@ func (u *window) promptAppUpdate(st lichclient.AppUpdateStatus) { // appUpdateToast offers the installer where lich may write its own install, // and otherwise the command to paste and the release page. -func (u *window) appUpdateToast(st lichclient.AppUpdateStatus) ui.Toast { +func (u *window) appUpdateToast(st backend.AppUpdateStatus) ui.Toast { version := st.LatestVersion later := &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.later"), OnClick: func() { u.writePref(appUpdateDismissedKey, version) }} t := ui.Toast{ID: appUpdateToastID, Title: i18n.T("shell.appUpdateGate.available", i18n.Args{"version": version}), Duration: ui.ToastForever, Cancel: later} @@ -134,7 +134,7 @@ func (u *window) applyUpdate(version string) { g.mu.Unlock() if err != nil { u.toaster.Show(ui.Toast{ID: appUpdateToastID, Variant: ui.ToastError, Duration: ui.ToastForever, - Title: updateFailureText(last, lichclient.ErrorText(err)), + Title: updateFailureText(last, backend.ErrorText(err)), Action: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.retry"), OnClick: func() { go u.applyUpdate(version) }}}) return } @@ -143,7 +143,7 @@ func (u *window) applyUpdate(version string) { Action: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.restart"), OnClick: func() { go func() { if err := u.client.Restart(u.ctx); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.appUpdateGate.restartFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.appUpdateGate.restartFailed", i18n.Args{"error": backend.ErrorText(err)})}) } }() }}}) @@ -151,7 +151,7 @@ func (u *window) applyUpdate(version string) { // updateProgressed is the progress event: it redraws the toast while an // apply runs, and is dropped otherwise. -func (u *window) updateProgressed(p lichclient.AppUpdateProgress) { +func (u *window) updateProgressed(p backend.AppUpdateProgress) { g := &u.appUpdate g.mu.Lock() version := g.applying @@ -166,12 +166,12 @@ func (u *window) updateProgressed(p lichclient.AppUpdateProgress) { // updateProgressToast is UpdateProgressToast: the download's bar, with its // percent and bytes under it, then the installer's step behind a spinner. -func updateProgressToast(version string, p *lichclient.AppUpdateProgress) ui.Toast { +func updateProgressToast(version string, p *backend.AppUpdateProgress) ui.Toast { t := ui.Toast{ID: appUpdateToastID, Duration: ui.ToastForever, Title: i18n.T("shell.updateProgressToast.downloading", i18n.Args{"version": version}), Progress: &ui.ToastProgress{Percent: -1}} switch { case p == nil: - case p.Phase == lichclient.UpdatePhaseInstaller: + case p.Phase == backend.UpdatePhaseInstaller: t.Title, t.Description, t.Progress = i18n.T("shell.updateProgressToast.installing", i18n.Args{"version": version}), i18n.T("shell.updateProgressToast.reopens"), nil t.Spin = true default: @@ -186,7 +186,7 @@ func updateProgressToast(version string, p *lichclient.AppUpdateProgress) ui.Toa // downloadReading reads a download's progress: percent is -1 while the total is // unknown. -func downloadReading(p lichclient.AppUpdateProgress) (percent int, bytes string) { +func downloadReading(p backend.AppUpdateProgress) (percent int, bytes string) { if p.Total <= 0 { return -1, megabytes(p.Received) } @@ -197,11 +197,11 @@ func megabytes(n int64) string { return fmt.Sprintf("%.1f MB", float64(n)/bytesP // updateFailureText names the phase the update died in and, for a download, // how far it got. -func updateFailureText(last *lichclient.AppUpdateProgress, err string) string { +func updateFailureText(last *backend.AppUpdateProgress, err string) string { switch { case last == nil: return i18n.T("update.progress.updateFailed", i18n.Args{"error": err}) - case last.Phase != lichclient.UpdatePhaseDownload: + case last.Phase != backend.UpdatePhaseDownload: return i18n.T("update.progress.installFailed", i18n.Args{"error": err}) } if percent, _ := downloadReading(*last); percent >= 0 { diff --git a/native/cmd/lich-native/app_update_gate_test.go b/native/cmd/lich-native/app_update_gate_test.go index b02b7ae28..0cddfe9a6 100644 --- a/native/cmd/lich-native/app_update_gate_test.go +++ b/native/cmd/lich-native/app_update_gate_test.go @@ -3,19 +3,19 @@ package main import ( "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) func TestShouldPromptUpdate(t *testing.T) { - newer := lichclient.AppUpdateStatus{UpdateAvailable: true, LatestVersion: "1.4.0"} + newer := backend.AppUpdateStatus{UpdateAvailable: true, LatestVersion: "1.4.0"} if !shouldPromptUpdate(newer, "") || !shouldPromptUpdate(newer, "1.3.0") { t.Error("a newer release Later was not clicked for did not prompt") } if shouldPromptUpdate(newer, "1.4.0") { t.Error("a release Later was clicked for prompted again") } - if shouldPromptUpdate(lichclient.AppUpdateStatus{LatestVersion: "1.4.0"}, "") { + if shouldPromptUpdate(backend.AppUpdateStatus{LatestVersion: "1.4.0"}, "") { t.Error("an up-to-date lich prompted") } } @@ -23,14 +23,14 @@ func TestShouldPromptUpdate(t *testing.T) { func TestUpdateProgressToast(t *testing.T) { cases := []struct { name string - p *lichclient.AppUpdateProgress + p *backend.AppUpdateProgress title, desc string bar *ui.ToastProgress }{ {"before the first step", nil, "Downloading lich 1.4.0", "", &ui.ToastProgress{Percent: -1}}, - {"known total", &lichclient.AppUpdateProgress{Phase: "download", Received: 12_300_000, Total: 41_000_000}, "Downloading lich 1.4.0", "30% · 12.3 MB of 41.0 MB", &ui.ToastProgress{Percent: 30}}, - {"no Content-Length", &lichclient.AppUpdateProgress{Phase: "download", Received: 5_000_000, Total: -1}, "Downloading lich 1.4.0", "5.0 MB", &ui.ToastProgress{Percent: -1}}, - {"installer", &lichclient.AppUpdateProgress{Phase: "installer"}, "Installing lich 1.4.0…", "lich closes and reopens on its own.", nil}, + {"known total", &backend.AppUpdateProgress{Phase: "download", Received: 12_300_000, Total: 41_000_000}, "Downloading lich 1.4.0", "30% · 12.3 MB of 41.0 MB", &ui.ToastProgress{Percent: 30}}, + {"no Content-Length", &backend.AppUpdateProgress{Phase: "download", Received: 5_000_000, Total: -1}, "Downloading lich 1.4.0", "5.0 MB", &ui.ToastProgress{Percent: -1}}, + {"installer", &backend.AppUpdateProgress{Phase: "installer"}, "Installing lich 1.4.0…", "lich closes and reopens on its own.", nil}, } for _, c := range cases { got := updateProgressToast("1.4.0", c.p) @@ -44,10 +44,10 @@ func TestUpdateProgressToast(t *testing.T) { } func TestUpdateProgressToastSpinsOnlyThroughTheInstaller(t *testing.T) { - if got := updateProgressToast("1.4.0", &lichclient.AppUpdateProgress{Phase: "installer"}); !got.Spin { + if got := updateProgressToast("1.4.0", &backend.AppUpdateProgress{Phase: "installer"}); !got.Spin { t.Error("installer: no spinner") } - for _, p := range []*lichclient.AppUpdateProgress{nil, {Phase: "download", Received: 1, Total: 2}} { + for _, p := range []*backend.AppUpdateProgress{nil, {Phase: "download", Received: 1, Total: 2}} { if got := updateProgressToast("1.4.0", p); got.Icon != nil || got.Spin { t.Errorf("%+v: icon %v spin %v, want no glyph while downloading", p, got.Icon, got.Spin) } @@ -56,13 +56,13 @@ func TestUpdateProgressToastSpinsOnlyThroughTheInstaller(t *testing.T) { func TestUpdateFailureTextNamesThePhase(t *testing.T) { cases := []struct { - last *lichclient.AppUpdateProgress + last *backend.AppUpdateProgress want string }{ {nil, "Update failed: boom"}, - {&lichclient.AppUpdateProgress{Phase: "installer"}, "Install failed: boom"}, - {&lichclient.AppUpdateProgress{Phase: "download", Received: 50, Total: 100}, "Download failed at 50%: boom"}, - {&lichclient.AppUpdateProgress{Phase: "download", Received: 50, Total: -1}, "Download failed: boom"}, + {&backend.AppUpdateProgress{Phase: "installer"}, "Install failed: boom"}, + {&backend.AppUpdateProgress{Phase: "download", Received: 50, Total: 100}, "Download failed at 50%: boom"}, + {&backend.AppUpdateProgress{Phase: "download", Received: 50, Total: -1}, "Download failed: boom"}, } for _, c := range cases { if got := updateFailureText(c.last, "boom"); got != c.want { diff --git a/native/cmd/lich-native/appearance.go b/native/cmd/lich-native/appearance.go index d7dd2072c..21045aa88 100644 --- a/native/cmd/lich-native/appearance.go +++ b/native/cmd/lich-native/appearance.go @@ -5,7 +5,7 @@ import ( "log" "sync" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // themeSettingKey is a global setting: "system" (the default), "light", "dark", @@ -22,12 +22,12 @@ const ( // takes the theme it resolves to whenever it changed and repaints. type appearance struct { ctx context.Context - client *lichclient.Client + client *backend.Client invalidate func() mu sync.Mutex themes []themeEntry - broken []lichclient.BrokenTheme + broken []backend.BrokenTheme selected string followSystem bool systemDark bool @@ -37,7 +37,7 @@ type appearance struct { // appearanceView is what the picker draws from. type appearanceView struct { themes []themeEntry - broken []lichclient.BrokenTheme + broken []backend.BrokenTheme selected string followSystem bool resolved themeEntry @@ -93,7 +93,7 @@ func (a *appearance) setFollowSystem(on bool) { // applySelection takes a selection the backend stored, from this window, // another, or `lich theme switch`. -func (a *appearance) applySelection(s lichclient.ThemeSelection) { +func (a *appearance) applySelection(s backend.ThemeSelection) { a.update(func() { a.selected, a.followSystem = s.Theme, s.FollowSystem }) } diff --git a/native/cmd/lich-native/appearance_prefs.go b/native/cmd/lich-native/appearance_prefs.go index 8ba936574..47dbd1d5e 100644 --- a/native/cmd/lich-native/appearance_prefs.go +++ b/native/cmd/lich-native/appearance_prefs.go @@ -14,10 +14,10 @@ import ( "gioui.org/text" "gioui.org/unit" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/fonts" "github.com/lichdotdev/lich/native/gioterm" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -125,7 +125,7 @@ func parseNumber(raw string, fallback float64) float64 { } // loadAppearancePrefs reads the stored values. -func loadAppearancePrefs(ctx context.Context, c *lichclient.Client) (appearancePrefs, error) { +func loadAppearancePrefs(ctx context.Context, c *backend.Client) (appearancePrefs, error) { raw := map[string]string{} for _, key := range []string{zoomKey, terminalFontSizeKey, terminalFontKey, editorFontSizeKey, editorFontKey, uiFontKey, costBudgetKey, closeActionKey} { v, err := c.GetSetting(ctx, key, "") @@ -172,13 +172,13 @@ func (u *window) applyAppearance(p appearancePrefs) { u.win.Option(app.TrafficLights(topbarHeight * unit.Dp(p.zoom))) } if err := u.terms.setFont(p.terminalFamily(), unit.Sp(p.fontSize)); err != nil { - u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.terminalFontFailed", i18n.Args{"family": p.terminalFamily(), "error": lichclient.ErrorText(err)}))) + u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.terminalFontFailed", i18n.Args{"family": p.terminalFamily(), "error": backend.ErrorText(err)}))) } if err := u.setCodeFont(p.codeFamily(), unit.Sp(p.editorFontSize)); err != nil { - u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.editorFontFailed", i18n.Args{"family": p.codeFamily(), "error": lichclient.ErrorText(err)}))) + u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.editorFontFailed", i18n.Args{"family": p.codeFamily(), "error": backend.ErrorText(err)}))) } if err := u.setInterfaceFonts(interfaceFonts{sans: p.uiFont, mono: p.codeFamily()}); err != nil { - u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.uiFontFailed", i18n.Args{"family": systemOr(p.uiFont, fonts.SystemSans), "error": lichclient.ErrorText(err)}))) + u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.uiFontFailed", i18n.Args{"family": systemOr(p.uiFont, fonts.SystemSans), "error": backend.ErrorText(err)}))) } u.footer.setBudget(p.budget) u.win.Invalidate() diff --git a/native/cmd/lich-native/attention_test.go b/native/cmd/lich-native/attention_test.go index f0a129dc9..57583ba5a 100644 --- a/native/cmd/lich-native/attention_test.go +++ b/native/cmd/lich-native/attention_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -18,9 +18,9 @@ type attentionRig struct { func newAttentionRig() *attentionRig { r := &attentionRig{m: newModel(nil, func() {})} - r.m.projects = []lichclient.Project{ - {ID: "p", Name: "lich", Sessions: []lichclient.Session{{ID: "a", Label: "fix the build"}, {ID: "b", Label: "Shell 2"}}}, - {ID: "q", Name: "skipo", Sessions: []lichclient.Session{{ID: "c", Label: "Claude 1"}}}, + r.m.projects = []backend.Project{ + {ID: "p", Name: "lich", Sessions: []backend.Session{{ID: "a", Label: "fix the build"}, {ID: "b", Label: "Shell 2"}}}, + {ID: "q", Name: "skipo", Sessions: []backend.Session{{ID: "c", Label: "Claude 1"}}}, } r.m.activeProject, r.m.active = "p", "a" a := attentionToast{show: func(t ui.Toast) { r.toasts = append(r.toasts, t) }, diff --git a/native/cmd/lich-native/binary_state.go b/native/cmd/lich-native/binary_state.go index 1d7b23d4f..1498e7128 100644 --- a/native/cmd/lich-native/binary_state.go +++ b/native/cmd/lich-native/binary_state.go @@ -12,8 +12,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -24,12 +24,12 @@ type fileExits struct { // update runs this frame's clicks against rel in checkout, off the UI // goroutine; a failure comes back as a toast. -func (e *fileExits) update(gtx C, client *lichclient.Client, notify func(ui.Toast), checkout, rel string) { +func (e *fileExits) update(gtx C, client *backend.Client, notify func(ui.Toast), checkout, rel string) { _, name := splitPath(rel) if e.open.Clicked(gtx) { go func() { if err := client.OpenFile(context.Background(), checkout, rel); err != nil { - notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.openFailed", i18n.Args{"name": name}), Description: lichclient.ErrorText(err)}) + notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.openFailed", i18n.Args{"name": name}), Description: backend.ErrorText(err)}) } }() } @@ -37,7 +37,7 @@ func (e *fileExits) update(gtx C, client *lichclient.Client, notify func(ui.Toas dir := filepath.Join(checkout, filepath.Dir(filepath.FromSlash(rel))) go func() { if err := client.OpenFolder(context.Background(), dir); err != nil { - notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.revealFailed", i18n.Args{"name": name}), Description: lichclient.ErrorText(err)}) + notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.revealFailed", i18n.Args{"name": name}), Description: backend.ErrorText(err)}) } }() } diff --git a/native/cmd/lich-native/blob_preview.go b/native/cmd/lich-native/blob_preview.go index 7117b7bf3..2928a4573 100644 --- a/native/cmd/lich-native/blob_preview.go +++ b/native/cmd/lich-native/blob_preview.go @@ -27,8 +27,8 @@ import ( _ "golang.org/x/image/bmp" _ "golang.org/x/image/webp" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -145,7 +145,7 @@ func newBlobView(invalidate func()) *blobView { // want reads rel at ref in checkout; version tells a new state of the file // from the one held. -func (v *blobView) want(client *lichclient.Client, checkout, rel, ref, version string) { +func (v *blobView) want(client *backend.Client, checkout, rel, ref, version string) { key := strings.Join([]string{checkout, rel, ref, version}, "\x00") v.read.want(key, checkout, func(ctx context.Context) (*previewImage, error) { data, err := client.Blob(ctx, checkout, rel, ref) @@ -158,7 +158,7 @@ func (v *blobView) want(client *lichclient.Client, checkout, rel, ref, version s // wantMergeBase reads rel at pull request pr's merge base, the commit its // old side stands on. -func (v *blobView) wantMergeBase(client *lichclient.Client, checkout, rel string, pr *prFiles, version string) { +func (v *blobView) wantMergeBase(client *backend.Client, checkout, rel string, pr *prFiles, version string) { number := pr.number key := strings.Join([]string{checkout, rel, strconv.Itoa(number), version}, "\x00") baseKey := strings.Join([]string{checkout, strconv.Itoa(number), pr.head}, "\x00") @@ -181,10 +181,10 @@ func (v *blobView) wantMergeBase(client *lichclient.Client, checkout, rel string func (v *blobView) layout(gtx C, th *ui.Theme) D { img, loading, err := v.read.get() switch { - case errors.Is(err, lichclient.ErrNoBlob): + case errors.Is(err, backend.ErrNoBlob): return th.Text(ui.TextXS, i18n.T("diff.binaryPreview.missing")).Muted().Layout(gtx) case err != nil: - l := th.Text(ui.TextXS, lichclient.ErrorText(err)).Muted() + l := th.Text(ui.TextXS, backend.ErrorText(err)).Muted() l.MaxLines = 0 return l.Layout(gtx) case loading || img == nil: diff --git a/native/cmd/lich-native/cardmenu_test.go b/native/cmd/lich-native/cardmenu_test.go index 52fb174cd..74c35c1c1 100644 --- a/native/cmd/lich-native/cardmenu_test.go +++ b/native/cmd/lich-native/cardmenu_test.go @@ -4,7 +4,7 @@ import ( "slices" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -28,7 +28,7 @@ func entry(t *testing.T, entries []ui.MenuEntry, label string) ui.MenuEntry { func TestCardMenu(t *testing.T) { var act sidebarAction renamed := false - sv := &sessionView{Session: lichclient.Session{ID: "s1", Kind: "claude", Color: "teal"}, shown: "/repo"} + sv := &sessionView{Session: backend.Session{ID: "s1", Kind: "claude", Color: "teal"}, shown: "/repo"} menu := cardMenu(sv, nil, &act, func() { renamed = true }, func() {}) if want := []string{"Rename", "Pin", "Color", "Move to folder", "Open in", "Pull request", "", "Close session"}; !slices.Equal(labels(menu), want) { @@ -62,7 +62,7 @@ func TestCardMenu(t *testing.T) { func TestCardMenuFilesIntoAnotherFolderOrOutOfItsOwn(t *testing.T) { var act sidebarAction asked := false - sv := &sessionView{Session: lichclient.Session{ID: "s1", Folder: "Apps"}} + sv := &sessionView{Session: backend.Session{ID: "s1", Folder: "Apps"}} sub := entry(t, cardMenu(sv, []string{"Docs"}, &act, func() {}, func() { asked = true }), "Move to folder").Sub if want := []string{"Docs", "Take out of Apps", "", "New folder…"}; !slices.Equal(labels(sub), want) { t.Fatalf("submenu %v, want %v", labels(sub), want) @@ -89,7 +89,7 @@ func TestCardMenuFilesIntoAnotherFolderOrOutOfItsOwn(t *testing.T) { func TestCardMenuForkEntry(t *testing.T) { var act sidebarAction - sv := &sessionView{Session: lichclient.Session{ID: "s1", Kind: "claude", ProviderSessionID: "conv-1"}} + sv := &sessionView{Session: backend.Session{ID: "s1", Kind: "claude", ProviderSessionID: "conv-1"}} fork := entry(t, cardMenu(sv, nil, &act, func() {}, func() {}), "Fork") if fork.Kind != ui.MenuSubTrigger || !slices.Equal(labels(fork.Sub), []string{"Here", "Worktree…"}) { t.Fatalf("fork entry %+v, want a submenu of Here and Worktree…", labels(fork.Sub)) @@ -106,7 +106,7 @@ func TestCardMenuForkEntry(t *testing.T) { t.Errorf("crush fork entry %+v, want it disabled, saying why, with no submenu", dead) } - for _, s := range []lichclient.Session{ + for _, s := range []backend.Session{ {ID: "s1", Kind: "claude"}, // no conversation to branch yet {ID: "s1", Kind: "shell", ProviderSessionID: "conv-1"}, // nothing a shell could fork } { diff --git a/native/cmd/lich-native/cardstate.go b/native/cmd/lich-native/cardstate.go index 4030520dc..ea535267a 100644 --- a/native/cmd/lich-native/cardstate.go +++ b/native/cmd/lich-native/cardstate.go @@ -2,38 +2,21 @@ package main import ( "context" - "encoding/json" "log" "time" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) -// agentEvent and sandboxEvent are internal/terminal's session-agent and -// session-sandbox payloads. -type agentEvent struct { - ID string `json:"id"` - Agent string `json:"agent"` -} - -// sessionRelay is a session-relay payload (internal/relay RelayEvent): the -// request a session has open with another. Peer is "" when the other end is -// the lich command line; an empty direction clears the mark. -type sessionRelay struct { - ID string `json:"id"` - Peer string `json:"peer"` - Direction string `json:"direction"` - Ticket string `json:"ticket"` -} +// sessionRelay is the request a session has open with another; an empty +// direction clears the mark. +type sessionRelay = api.SessionRelay // sandboxEvent is whether a spawn ran confined, and what that sandbox left // out of the private home for being a symlink. -type sandboxEvent struct { - ID string `json:"id"` - Confined bool `json:"confined"` - SkippedLinks []string `json:"skippedLinks"` -} +type sandboxEvent = api.SessionSandbox // Directions a relay mark runs (internal/relay DirectionOut, DirectionIn). const ( @@ -44,17 +27,14 @@ const ( // applyStatus feeds a session-status report to the status store. An idle // report is the provider CLI leaving the PTY, so the card drops its agent // mark, and a report on the session being watched is read on arrival. -func (m *model) applyStatus(ctx context.Context, ev lichclient.Event) error { - var s struct { - ID string `json:"id"` - State string `json:"state"` - } - if err := json.Unmarshal(ev.Data, &s); err != nil { +func (m *model) applyStatus(ctx context.Context, ev backend.Event) error { + s, err := backend.Decode(ev, api.SessionStatusEvent) + if err != nil { return err } m.mu.Lock() previous := m.status.status(s.ID) - _, err := m.status.apply(ev, time.Now()) + _, err = m.status.apply(ev, time.Now()) report, routed := m.statusReport(s.ID, previous) var recheck string if previous != statusDone && m.status.status(s.ID) == statusDone { @@ -100,11 +80,11 @@ func (m *model) statusReport(id, previous string) (statusReport, bool) { // applyLive takes the live PTY reports the backend never replays: the // provider CLI running in a session, and whether its spawn ran confined. It // reports whether ev was one of them. -func (m *model) applyLive(ev lichclient.Event) (bool, error) { +func (m *model) applyLive(ev backend.Event) (bool, error) { switch ev.Name { - case "session-agent": - var a agentEvent - if err := json.Unmarshal(ev.Data, &a); err != nil { + case api.SessionAgentEvent.Name: + a, err := backend.Decode(ev, api.SessionAgentEvent) + if err != nil { return true, err } m.mu.Lock() @@ -116,9 +96,9 @@ func (m *model) applyLive(ev lichclient.Event) (bool, error) { } else { delete(m.agent, a.ID) } - case "session-relay": - var r sessionRelay - if err := json.Unmarshal(ev.Data, &r); err != nil { + case api.SessionRelayEvent.Name: + r, err := backend.Decode(ev, api.SessionRelayEvent) + if err != nil { return true, err } m.mu.Lock() @@ -128,9 +108,9 @@ func (m *model) applyLive(ev lichclient.Event) (bool, error) { } else { delete(m.relay, r.ID) } - case "session-sandbox": - var s sandboxEvent - if err := json.Unmarshal(ev.Data, &s); err != nil { + case api.SessionSandboxEvent.Name: + s, err := backend.Decode(ev, api.SessionSandboxEvent) + if err != nil { return true, err } m.mu.Lock() diff --git a/native/cmd/lich-native/cardstate_test.go b/native/cmd/lich-native/cardstate_test.go index 53da8df63..e28942263 100644 --- a/native/cmd/lich-native/cardstate_test.go +++ b/native/cmd/lich-native/cardstate_test.go @@ -11,7 +11,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) func TestCardWearsTheLiveAgentUntilItLeaves(t *testing.T) { @@ -21,13 +22,13 @@ func TestCardWearsTheLiveAgentUntilItLeaves(t *testing.T) { data any want string }{ - {"session-agent", agentEvent{ID: "s1", Agent: "claude"}, "claude"}, - {"session-agent", agentEvent{ID: "s1", Agent: ""}, ""}, - {"session-agent", agentEvent{ID: "s1", Agent: "codex"}, "codex"}, - {"session-agent", agentEvent{ID: "s1", Agent: "from-a-newer-lich"}, ""}, - {"session-agent", agentEvent{ID: "s1", Agent: "claude"}, "claude"}, - {statusEventName, map[string]string{"id": "s1", "state": "busy"}, "claude"}, - {statusEventName, map[string]string{"id": "s1", "state": "idle"}, ""}, + {"session-agent", api.SessionAgent{ID: "s1", Agent: "claude"}, "claude"}, + {"session-agent", api.SessionAgent{ID: "s1", Agent: ""}, ""}, + {"session-agent", api.SessionAgent{ID: "s1", Agent: "codex"}, "codex"}, + {"session-agent", api.SessionAgent{ID: "s1", Agent: "from-a-newer-lich"}, ""}, + {"session-agent", api.SessionAgent{ID: "s1", Agent: "claude"}, "claude"}, + {api.SessionStatusEvent.Name, map[string]string{"id": "s1", "state": "busy"}, "claude"}, + {api.SessionStatusEvent.Name, map[string]string{"id": "s1", "state": "idle"}, ""}, } for i, s := range steps { send(m, s.name, s.data) @@ -61,9 +62,9 @@ func TestPinFlipsTheCard(t *testing.T) { // fakeUnread serves LoadState with one unread session and records every // SetSessionUnread call. -func fakeUnread(t *testing.T, calls chan<- []any) *lichclient.Client { +func fakeUnread(t *testing.T, calls chan<- []any) *backend.Client { t.Helper() - projects := []lichclient.Project{{ID: "p", Path: checkout, Sessions: []lichclient.Session{{ID: "s1", Unread: true}, {ID: "s2"}}}} + projects := []backend.Project{{ID: "p", Path: checkout, Sessions: []backend.Session{{ID: "s1", Unread: true}, {ID: "s2"}}}} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/rpc/store.LoadState": @@ -71,14 +72,14 @@ func fakeUnread(t *testing.T, calls chan<- []any) *lichclient.Client { case "/rpc/store.GetSetting": _ = json.NewEncoder(w).Encode("") case "/rpc/project.Home": - _ = json.NewEncoder(w).Encode(lichclient.Project{ID: "home", Path: "/home/me"}) + _ = json.NewEncoder(w).Encode(backend.Project{ID: "home", Path: "/home/me"}) case "/rpc/store.AddProject", "/rpc/store.AddSession": _ = json.NewEncoder(w).Encode(nil) case "/rpc/store.SetSessionUnread": - var args []any + var args []api.SessionUnreadOptions body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &args) - calls <- args + calls <- []any{args[0].SessionID, args[0].Unread} _ = json.NewEncoder(w).Encode(nil) default: t.Errorf("unexpected call %s", r.URL.Path) @@ -87,7 +88,7 @@ func fakeUnread(t *testing.T, calls chan<- []any) *lichclient.Client { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func unreadOf(m *model, id string) bool { @@ -138,7 +139,7 @@ func TestRelayMarkFollowsTheRequest(t *testing.T) { {"session-relay", sessionRelay{ID: "s1", Direction: relayIn}, sessionRelay{ID: "s1", Direction: relayIn}}, {"session-relay", sessionRelay{ID: "s1", Direction: "sideways"}, sessionRelay{}}, {"session-relay", sessionRelay{ID: "s1", Direction: relayIn}, sessionRelay{ID: "s1", Direction: relayIn}}, - {statusEventName, map[string]string{"id": "s1", "state": "idle"}, sessionRelay{}}, + {api.SessionStatusEvent.Name, map[string]string{"id": "s1", "state": "idle"}, sessionRelay{}}, } for i, s := range steps { send(m, s.name, s.data) diff --git a/native/cmd/lich-native/close_undo.go b/native/cmd/lich-native/close_undo.go index 23d2aa921..a06fd8c4d 100644 --- a/native/cmd/lich-native/close_undo.go +++ b/native/cmd/lich-native/close_undo.go @@ -6,8 +6,8 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -15,7 +15,7 @@ const undoToastDuration = 10 * time.Second // closedToast is closeSession's toast: Undo puts s back at index of its // project once parked reports the park done. -func (u *window) closedToast(projectID string, s lichclient.Session, index int, parked <-chan error) ui.Toast { +func (u *window) closedToast(projectID string, s backend.Session, index int, parked <-chan error) ui.Toast { return ui.Toast{Title: i18n.T("shell.projects.closed", i18n.Args{"label": s.Label}), Duration: undoToastDuration, Action: &ui.ToastButton{Label: i18n.T("shell.projects.undo"), OnClick: func() { go u.undoClose(projectID, s, index, parked) @@ -28,7 +28,7 @@ func (u *window) closedToast(projectID string, s lichclient.Session, index int, // id, unspawned, so the spawn gate offers the conversation's resume as for // any restored card; when another open card already runs that conversation, // the store hands that card back instead and the undo only focuses it. -func (u *window) undoClose(projectID string, s lichclient.Session, index int, parked <-chan error) { +func (u *window) undoClose(projectID string, s backend.Session, index int, parked <-chan error) { if err := <-parked; err != nil { return // closeSession logged it; there is no parked row to bring back } @@ -48,12 +48,12 @@ func (u *window) undoClose(projectID string, s lichclient.Session, index int, pa } // slotOf is the open session with id and its place in its project. -func (m *model) slotOf(id string) (lichclient.Session, int, bool) { +func (m *model) slotOf(id string) (backend.Session, int, bool) { m.mu.Lock() defer m.mu.Unlock() p, i := m.sessionAt(id) if p == nil { - return lichclient.Session{}, -1, false + return backend.Session{}, -1, false } return p.Sessions[i], i, true } @@ -61,15 +61,15 @@ func (m *model) slotOf(id string) (lichclient.Session, int, bool) { // restoreSession puts s back into its project at index, or the end when the // project has fewer, and makes it the project's active session. It returns // the project's card order, and false when the project is no longer open. -func (m *model) restoreSession(projectID string, s lichclient.Session, index int) ([]string, bool) { +func (m *model) restoreSession(projectID string, s backend.Session, index int) ([]string, bool) { m.mu.Lock() defer m.mu.Unlock() - pi := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == projectID }) + pi := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == projectID }) if pi < 0 { return nil, false } p := &m.projects[pi] - if !slices.ContainsFunc(p.Sessions, func(o lichclient.Session) bool { return o.ID == s.ID }) { + if !slices.ContainsFunc(p.Sessions, func(o backend.Session) bool { return o.ID == s.ID }) { s.Path = checkoutPath(s.Path, p.Path) p.Sessions = slices.Insert(p.Sessions, min(index, len(p.Sessions)), s) } diff --git a/native/cmd/lich-native/close_undo_test.go b/native/cmd/lich-native/close_undo_test.go index 59015e229..46305dd61 100644 --- a/native/cmd/lich-native/close_undo_test.go +++ b/native/cmd/lich-native/close_undo_test.go @@ -4,12 +4,12 @@ import ( "slices" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestUndoPutsTheReopenedRowBackAtItsSlotAndOnScreen(t *testing.T) { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Path: "/repo", Sessions: []lichclient.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}}}} + m.projects = []backend.Project{{ID: "p", Path: "/repo", Sessions: []backend.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}}}} m.activeProject, m.active = "p", "b" closed, index, _ := m.slotOf("b") @@ -32,12 +32,12 @@ func TestUndoPutsTheReopenedRowBackAtItsSlotAndOnScreen(t *testing.T) { func TestUndoPastTheEndAppendsAndABackgroundProjectKeepsTheScreen(t *testing.T) { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{ - {ID: "p", Sessions: []lichclient.Session{{ID: "a"}}}, - {ID: "q", Sessions: []lichclient.Session{{ID: "x"}}}, + m.projects = []backend.Project{ + {ID: "p", Sessions: []backend.Session{{ID: "a"}}}, + {ID: "q", Sessions: []backend.Session{{ID: "x"}}}, } m.activeProject, m.active = "p", "a" - ids, ok := m.restoreSession("q", lichclient.Session{ID: "y"}, 5) + ids, ok := m.restoreSession("q", backend.Session{ID: "y"}, 5) if !ok || !slices.Equal(ids, []string{"x", "y"}) { t.Fatalf("order %v, want y at the end", ids) } @@ -48,16 +48,16 @@ func TestUndoPastTheEndAppendsAndABackgroundProjectKeepsTheScreen(t *testing.T) func TestUndoIntoAClosedProjectIsDropped(t *testing.T) { m := newModel(nil, func() {}) - if _, ok := m.restoreSession("gone", lichclient.Session{ID: "y"}, 0); ok { + if _, ok := m.restoreSession("gone", backend.Session{ID: "y"}, 0); ok { t.Fatal("restored into a project that is no longer open") } } func TestUndoOfARowAlreadyBackOnlyActivatesIt(t *testing.T) { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Sessions: []lichclient.Session{{ID: "a"}, {ID: "b"}}}} + m.projects = []backend.Project{{ID: "p", Sessions: []backend.Session{{ID: "a"}, {ID: "b"}}}} m.activeProject, m.active = "p", "a" - ids, _ := m.restoreSession("p", lichclient.Session{ID: "b"}, 0) + ids, _ := m.restoreSession("p", backend.Session{ID: "b"}, 0) if !slices.Equal(ids, []string{"a", "b"}) || m.active != "b" { t.Fatalf("order %v, active %q: want no duplicate, b active", ids, m.active) } diff --git a/native/cmd/lich-native/close_worktree.go b/native/cmd/lich-native/close_worktree.go index 7ce34652a..bb7a19cdb 100644 --- a/native/cmd/lich-native/close_worktree.go +++ b/native/cmd/lich-native/close_worktree.go @@ -8,24 +8,24 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) // worktreeLeftBy is the project and the worktree whose last open session is // id, the close that decides that checkout's fate. path is empty when the // session shares its checkout or runs in the project's own directory. -func (m *model) worktreeLeftBy(id string) (project lichclient.Project, path string) { +func (m *model) worktreeLeftBy(id string) (project backend.Project, path string) { m.mu.Lock() defer m.mu.Unlock() p, i := m.sessionAt(id) if p == nil || p.Sessions[i].Path == "" { - return lichclient.Project{}, "" + return backend.Project{}, "" } path = p.Sessions[i].Path - if slices.ContainsFunc(p.Sessions, func(s lichclient.Session) bool { return s.ID != id && s.Path == path }) { - return lichclient.Project{}, "" + if slices.ContainsFunc(p.Sessions, func(s backend.Session) bool { return s.ID != id && s.Path == path }) { + return backend.Project{}, "" } return *p, path } @@ -34,7 +34,7 @@ func (m *model) worktreeLeftBy(id string) (project lichclient.Project, path stri // already parked the session, so what is left is the checkout. One lich did // not create, or one holding uncommitted work, is named in a dialog before it // goes, as useWorktreeClose does; a failed check reads as the half that asks. -func (u *window) removeClosedWorktree(project lichclient.Project, path string, parked <-chan error) { +func (u *window) removeClosedWorktree(project backend.Project, path string, parked <-chan error) { if err := <-parked; err != nil { return // closeSession logged it; the session still holds the checkout } @@ -57,13 +57,13 @@ type closeWorktreeConfirm struct { // mu guards the question, which the removal asks off the frame. mu sync.Mutex open bool - project lichclient.Project + project backend.Project path string adopted, dirty bool cancel, confirm widget.Clickable } -func (c *closeWorktreeConfirm) ask(project lichclient.Project, path string, adopted, dirty bool) { +func (c *closeWorktreeConfirm) ask(project backend.Project, path string, adopted, dirty bool) { c.mu.Lock() c.open, c.project, c.path, c.adopted, c.dirty = true, project, path, adopted, dirty c.mu.Unlock() diff --git a/native/cmd/lich-native/close_worktree_test.go b/native/cmd/lich-native/close_worktree_test.go index ad6bbc8e3..710eab203 100644 --- a/native/cmd/lich-native/close_worktree_test.go +++ b/native/cmd/lich-native/close_worktree_test.go @@ -4,12 +4,12 @@ import ( "strings" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestWorktreeLeftByOnlyTheLastSessionOfAWorktree(t *testing.T) { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Path: "/repo", Sessions: []lichclient.Session{ + m.projects = []backend.Project{{ID: "p", Path: "/repo", Sessions: []backend.Session{ {ID: "root"}, {ID: "a", Path: "/wt/a"}, {ID: "b1", Path: "/wt/b"}, {ID: "b2", Path: "/wt/b"}, }}} for id, want := range map[string]string{"root": "", "a": "/wt/a", "b1": "", "gone": ""} { diff --git a/native/cmd/lich-native/closed_projects.go b/native/cmd/lich-native/closed_projects.go index 4e40fdfca..5dc22d100 100644 --- a/native/cmd/lich-native/closed_projects.go +++ b/native/cmd/lich-native/closed_projects.go @@ -5,7 +5,7 @@ import ( "log" "sync" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // closedProjects is the projects closed earlier, as the window knows them: one @@ -18,7 +18,7 @@ import ( // Only the plain list and the palette's current term are kept: the palette // types its way through terms and an answer for each would only pile up. type closedProjects struct { - client *lichclient.Client + client *backend.Client invalidate func() mu sync.Mutex @@ -36,7 +36,7 @@ type closedEntry struct { answer closedAnswer } -func newClosedProjects(c *lichclient.Client, invalidate func()) *closedProjects { +func newClosedProjects(c *backend.Client, invalidate func()) *closedProjects { return &closedProjects{client: c, invalidate: invalidate, entries: map[string]*closedEntry{}} } @@ -85,7 +85,7 @@ func (b *closedProjects) fetch(term string, e *closedEntry) { b.invalidate() } -func fetchClosed(ctx context.Context, c *lichclient.Client, term string) (closedAnswer, error) { +func fetchClosed(ctx context.Context, c *backend.Client, term string) (closedAnswer, error) { rows, err := c.RecentProjectsMatching(ctx, term) if err != nil { return closedAnswer{}, err diff --git a/native/cmd/lich-native/desktop_notes.go b/native/cmd/lich-native/desktop_notes.go index 3e8c11508..26fbf121c 100644 --- a/native/cmd/lich-native/desktop_notes.go +++ b/native/cmd/lich-native/desktop_notes.go @@ -5,7 +5,7 @@ import ( "log" "strings" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // desktopNote is one session's desktop notification. A session has at most @@ -83,7 +83,7 @@ func (r recapping) withdraw(id string) { r.next.withdraw(id) } // RPC: no click, no replacing, nothing to take down. type backendNotes struct { ctx context.Context - client *lichclient.Client + client *backend.Client } func (b backendNotes) show(n desktopNote) { diff --git a/native/cmd/lich-native/dock.go b/native/cmd/lich-native/dock.go index 67787e282..429dbaa91 100644 --- a/native/cmd/lich-native/dock.go +++ b/native/cmd/lich-native/dock.go @@ -37,8 +37,8 @@ import ( "gioui.org/widget" "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -89,7 +89,7 @@ type dockInput struct { inject func(string) bool submit func(string, func(error)) bool checkout string - git lichclient.DiffStats + git backend.DiffStats isGit bool light bool code godemirror.Theme @@ -302,11 +302,11 @@ func parseDockWidth(raw string) float64 { return min(dockMaxRem, max(dockMinRem, rem)) } -func loadDockWidth(ctx context.Context, c *lichclient.Client) (float64, error) { +func loadDockWidth(ctx context.Context, c *backend.Client) (float64, error) { raw, err := c.GetSetting(ctx, dockWidthKey, "") return parseDockWidth(raw), err } -func saveDockWidth(ctx context.Context, c *lichclient.Client, rem float64) error { +func saveDockWidth(ctx context.Context, c *backend.Client, rem float64) error { return c.SetSetting(ctx, dockWidthKey, "", strconv.FormatFloat(rem, 'f', -1, 64)) } diff --git a/native/cmd/lich-native/dock_code.go b/native/cmd/lich-native/dock_code.go index feb676784..2c106849f 100644 --- a/native/cmd/lich-native/dock_code.go +++ b/native/cmd/lich-native/dock_code.go @@ -20,8 +20,8 @@ import ( "gioui.org/widget" "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -35,12 +35,7 @@ const ( browseByText = "text" ) -// fileListing mirrors internal/project.FileListing. -type fileListing struct { - Files []string `json:"files"` - Cut bool `json:"cut"` - Hidden []string `json:"hidden"` -} +type fileListing = backend.FileListing // treeData is one read of the checkout: its files and each changed file's // counts. @@ -50,7 +45,7 @@ type treeData struct { } type codeTab struct { - client *lichclient.Client + client *backend.Client comments *commentStore notify func(ui.Toast) @@ -76,7 +71,7 @@ type codeTab struct { batch commentBatch } -func newCodeTab(client *lichclient.Client, invalidate func(), family string, size unit.Sp, comments *commentStore, notify func(ui.Toast)) (*codeTab, error) { +func newCodeTab(client *backend.Client, invalidate func(), family string, size unit.Sp, comments *commentStore, notify func(ui.Toast)) (*codeTab, error) { view, err := newCodeView(family, size) if err != nil { return nil, err @@ -165,9 +160,9 @@ func (c *codeTab) fetchTree(gtx C, in dockInput) { c.tree.want(key, path, func(ctx context.Context) (*treeData, error) { return readTree(ctx, c.client, path) }) } -func readTree(ctx context.Context, client *lichclient.Client, path string) (*treeData, error) { - var l fileListing - if err := client.Call(ctx, "project.Tree", &l, path); err != nil { +func readTree(ctx context.Context, client *backend.Client, path string) (*treeData, error) { + l, err := client.Tree(ctx, path) + if err != nil { return nil, err } // The diff only feeds the rows' +/- badges: a checkout with nothing to diff --git a/native/cmd/lich-native/dock_code_markdown.go b/native/cmd/lich-native/dock_code_markdown.go index 63d69d77a..1f68f63c1 100644 --- a/native/cmd/lich-native/dock_code_markdown.go +++ b/native/cmd/lich-native/dock_code_markdown.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -33,7 +33,7 @@ func (p *preview) markdownBody(gtx C, th *ui.Theme) D { text, loading, err := p.file.get() switch { case err != nil: - return binaryState(gtx, th, lichclient.ErrorText(err), "", &p.exits) + return binaryState(gtx, th, backend.ErrorText(err), "", &p.exits) case loading: return notice(gtx, th, i18n.T("dock.filesPanel.loading")) } diff --git a/native/cmd/lich-native/dock_code_preview.go b/native/cmd/lich-native/dock_code_preview.go index 49e369f1f..207427efd 100644 --- a/native/cmd/lich-native/dock_code_preview.go +++ b/native/cmd/lich-native/dock_code_preview.go @@ -14,8 +14,8 @@ import ( "gioui.org/widget" "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -23,7 +23,7 @@ import ( var icPreviewBack = icons.Lucide("chevron-left") type preview struct { - client *lichclient.Client + client *backend.Client view *godemirror.View family string size unit.Sp @@ -59,7 +59,7 @@ type previewEnv struct { notify func(ui.Toast) } -func newPreview(client *lichclient.Client, view *godemirror.View, family string, size unit.Sp, invalidate func()) *preview { +func newPreview(client *backend.Client, view *godemirror.View, family string, size unit.Sp, invalidate func()) *preview { p := &preview{client: client, view: view, family: family, size: size, blockAt: -1, image: newBlobView(invalidate), find: newFindBar()} p.file.invalidate = invalidate p.scroll.Axis = layout.Vertical @@ -97,8 +97,7 @@ func (p *preview) layout(gtx C, th *ui.Theme, root *ui.Root, env previewEnv) (D, p.image.want(p.client, path, rel, "", "") case blobNone: p.file.want(key, key, func(ctx context.Context) (string, error) { - var text string - return text, p.client.Call(ctx, "project.ReadFile", &text, path, rel) + return p.client.ReadFile(ctx, path, rel) }) } if p.md.toggle.click.Clicked(gtx) { @@ -204,7 +203,7 @@ func (p *preview) body(gtx C, th *ui.Theme, root *ui.Root, key string) (D, error text, loading, err := p.file.get() switch { case err != nil: - return binaryState(gtx, th, lichclient.ErrorText(err), "", &p.exits), nil + return binaryState(gtx, th, backend.ErrorText(err), "", &p.exits), nil case loading: return notice(gtx, th, i18n.T("dock.filesPanel.loading")), nil } diff --git a/native/cmd/lich-native/dock_inject.go b/native/cmd/lich-native/dock_inject.go index 3c027cddd..08b2328df 100644 --- a/native/cmd/lich-native/dock_inject.go +++ b/native/cmd/lich-native/dock_inject.go @@ -11,8 +11,8 @@ import ( "strconv" "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -77,7 +77,7 @@ func (ts *terminals) submitter(id string) func(string, func(error)) bool { go func() { err := ts.client.Submit(context.Background(), id, text) if err != nil { - ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.commentBatch.sendFailed"), Description: lichclient.ErrorText(err)}) + ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.commentBatch.sendFailed"), Description: backend.ErrorText(err)}) } done(err) ts.invalidate() diff --git a/native/cmd/lich-native/dock_review.go b/native/cmd/lich-native/dock_review.go index ab7fd970c..8ec8cd4c0 100644 --- a/native/cmd/lich-native/dock_review.go +++ b/native/cmd/lich-native/dock_review.go @@ -21,9 +21,9 @@ import ( "gioui.org/widget" "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/glyphs" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -38,7 +38,7 @@ const splitMinWidth = unit.Dp(560) // reviewPanel is the Review tab. Every field is the UI goroutine's but the // gap answers, which arrive from the backend. type reviewPanel struct { - client *lichclient.Client + client *backend.Client root *ui.Root comments *commentStore notify func(ui.Toast) @@ -121,7 +121,7 @@ type gapAnswer struct { lines []string } -func newReviewPanel(client *lichclient.Client, root *ui.Root, comments *commentStore, +func newReviewPanel(client *backend.Client, root *ui.Root, comments *commentStore, family string, size unit.Sp, invalidate func(), notify func(ui.Toast)) (*reviewPanel, error) { cells, err := glyphs.New(family) if err != nil { @@ -308,14 +308,14 @@ func (p *reviewPanel) written(checkout string) { // revert puts changed lines of rel back to HEAD, with no confirmation: the // toast carries the undo for as long as it stays up. -func (p *reviewPanel) revert(rel string, lines []lichclient.RevertLine) { +func (p *reviewPanel) revert(rel string, lines []backend.RevertLine) { path := p.target _, name := splitPath(rel) go func() { defer p.written(path) undo, err := p.client.RevertLines(context.Background(), path, rel, lines) if err != nil { - p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.reviewPanel.revertFailed", i18n.Args{"name": name}), Description: lichclient.ErrorText(err)}) + p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.reviewPanel.revertFailed", i18n.Args{"name": name}), Description: backend.ErrorText(err)}) return } added, deleted := revertStat(lines) @@ -324,15 +324,15 @@ func (p *reviewPanel) revert(rel string, lines []lichclient.RevertLine) { }() } -func (p *reviewPanel) restore(path, rel, name string, undo lichclient.RevertResult) { +func (p *reviewPanel) restore(path, rel, name string, undo backend.RevertResult) { defer p.written(path) - if err := p.client.Call(context.Background(), "project.RestoreLines", nil, path, rel, undo); err != nil { - p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.reviewPanel.undoFailed", i18n.Args{"name": name}), Description: lichclient.ErrorText(err)}) + if err := p.client.RestoreLines(context.Background(), path, rel, undo); err != nil { + p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.reviewPanel.undoFailed", i18n.Args{"name": name}), Description: backend.ErrorText(err)}) } } // revertStat counts a revert the way DiffStat draws a diff. -func revertStat(lines []lichclient.RevertLine) (added, deleted int) { +func revertStat(lines []backend.RevertLine) (added, deleted int) { for _, l := range lines { if l.Side == "new" { added++ @@ -378,8 +378,8 @@ func (p *reviewPanel) discard(f patchFile) { go func() { defer p.written(path) for _, rel := range discardTargets(f) { - if err := p.client.Call(context.Background(), "project.DiscardFile", nil, path, rel); err != nil { - p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.reviewPanel.discardFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + if err := p.client.DiscardFile(context.Background(), path, rel); err != nil { + p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.reviewPanel.discardFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } } diff --git a/native/cmd/lich-native/dock_review_binary.go b/native/cmd/lich-native/dock_review_binary.go index b42832a75..51341076e 100644 --- a/native/cmd/lich-native/dock_review_binary.go +++ b/native/cmd/lich-native/dock_review_binary.go @@ -15,8 +15,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -61,7 +61,7 @@ func (c *fileCard) prBinary(gtx C, p *reviewPanel) D { _, name := splitPath(c.git.path) go func() { if err := p.client.OpenExternal(context.Background(), url); err != nil { - p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.openFailed", i18n.Args{"name": name}), Description: lichclient.ErrorText(err)}) + p.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.openFailed", i18n.Args{"name": name}), Description: backend.ErrorText(err)}) } }() } diff --git a/native/cmd/lich-native/dock_review_body.go b/native/cmd/lich-native/dock_review_body.go index c62fbe4f3..446e635a5 100644 --- a/native/cmd/lich-native/dock_review_body.go +++ b/native/cmd/lich-native/dock_review_body.go @@ -6,8 +6,8 @@ import ( "gioui.org/layout" "gioui.org/unit" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -18,7 +18,7 @@ var ( icSplit = icons.Lucide("columns-2") ) -func loadHideWhitespace(ctx context.Context, c *lichclient.Client) (bool, error) { +func loadHideWhitespace(ctx context.Context, c *backend.Client) (bool, error) { raw, err := c.GetSetting(ctx, hideWhitespaceKey, "") return raw == "true", err } diff --git a/native/cmd/lich-native/dock_review_markdown.go b/native/cmd/lich-native/dock_review_markdown.go index 610dcb6a2..3033c1967 100644 --- a/native/cmd/lich-native/dock_review_markdown.go +++ b/native/cmd/lich-native/dock_review_markdown.go @@ -9,8 +9,8 @@ import ( "gioui.org/layout" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -70,7 +70,7 @@ func (c *fileCard) markdownBody(gtx C, p *reviewPanel) D { src, loading, err := c.markdownSource(p) switch { case err != nil: - return bodyLine(gtx, p.th, lichclient.ErrorText(err)) + return bodyLine(gtx, p.th, backend.ErrorText(err)) case loading: return bodyLine(gtx, p.th, i18n.T("diff.binaryPreview.loading")) } @@ -95,20 +95,19 @@ func (c *fileCard) markdownSource(p *reviewPanel) (src string, loading bool, err } key := strings.Join([]string{checkout, rel, ref, c.git.blobs}, "\x00") c.md.read.want(key, checkout, func(ctx context.Context) (string, error) { - var text string if ref == "" { - return text, client.Call(ctx, "project.ReadFile", &text, checkout, rel) + return client.ReadFile(ctx, checkout, rel) } - return text, client.Call(ctx, "project.ReadFileAt", &text, checkout, rel, ref) + return client.ReadFileAt(ctx, checkout, rel, ref) }) return c.md.read.get() } // openLink sends a link clicked in a Markdown preview to the browser. -func openLink(client *lichclient.Client, notify func(ui.Toast), url string) { +func openLink(client *backend.Client, notify func(ui.Toast), url string) { go func() { if err := client.OpenExternal(context.Background(), url); err != nil { - notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.openFailed", i18n.Args{"name": url}), Description: lichclient.ErrorText(err)}) + notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("diff.binaryState.openFailed", i18n.Args{"name": url}), Description: backend.ErrorText(err)}) } }() } diff --git a/native/cmd/lich-native/dock_review_source.go b/native/cmd/lich-native/dock_review_source.go index ef180d3ac..89fd29567 100644 --- a/native/cmd/lich-native/dock_review_source.go +++ b/native/cmd/lich-native/dock_review_source.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // diffPoll is ReviewPanel's DIFF_POLL_MS: how often the diff is read again, @@ -23,7 +23,7 @@ type diffRead struct { // diffFeed reads a checkout's diff off the UI goroutine, again every // diffPoll and at once on refresh, and keeps the newest read for the frame. type diffFeed struct { - client *lichclient.Client + client *backend.Client invalidate func() mu sync.Mutex diff --git a/native/cmd/lich-native/dock_review_test.go b/native/cmd/lich-native/dock_review_test.go index 2fe45d38c..4d0500c57 100644 --- a/native/cmd/lich-native/dock_review_test.go +++ b/native/cmd/lich-native/dock_review_test.go @@ -11,7 +11,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -185,7 +186,7 @@ func TestStickyHeaderIsPushedUpByTheNextCard(t *testing.T) { } // fakeRevert serves RevertLines with undo and records every RestoreLines. -func fakeRevert(t *testing.T, undo lichclient.RevertResult, restored chan<- []json.RawMessage) *lichclient.Client { +func fakeRevert(t *testing.T, undo backend.RevertResult, restored chan<- []json.RawMessage) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -204,15 +205,15 @@ func fakeRevert(t *testing.T, undo lichclient.RevertResult, restored chan<- []js t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func TestARevertsUndoPutsTheLinesBack(t *testing.T) { - undo := lichclient.RevertResult{Patch: "the patch", IndexPatch: "the index"} + undo := backend.RevertResult{Patch: "the patch", IndexPatch: "the index"} restored := make(chan []json.RawMessage, 1) toasts := make(chan ui.Toast, 1) p := &reviewPanel{client: fakeRevert(t, undo, restored), target: "/repo", notify: func(t ui.Toast) { toasts <- t }} - p.revert("internal/a.go", []lichclient.RevertLine{{Side: "new", Line: 3, Text: "x"}, {Side: "old", Line: 3, Text: "y"}}) + p.revert("internal/a.go", []backend.RevertLine{{Side: "new", Line: 3, Text: "x"}, {Side: "old", Line: 3, Text: "y"}}) toast := <-toasts if toast.Title != "Reverted +1 -1 in a.go" || toast.Duration != revertUndoFor || toast.Action == nil { t.Fatalf("toast = %+v, want the revert's stat and a long-lived Undo", toast) @@ -220,9 +221,9 @@ func TestARevertsUndoPutsTheLinesBack(t *testing.T) { toast.Action.OnClick() select { case args := <-restored: - var gotUndo lichclient.RevertResult - _ = json.Unmarshal(args[2], &gotUndo) - if string(args[0]) != `"/repo"` || string(args[1]) != `"internal/a.go"` || gotUndo != undo { + var got api.RestoreLinesOptions + _ = json.Unmarshal(args[0], &got) + if got.Path != "/repo" || got.Rel != "internal/a.go" || got.Undo != undo { t.Fatalf("RestoreLines(%s) — want the checkout, the file and the revert's own undo", args) } case <-time.After(5 * time.Second): diff --git a/native/cmd/lich-native/dock_search.go b/native/cmd/lich-native/dock_search.go index 2ec4e6574..4e0cc8c84 100644 --- a/native/cmd/lich-native/dock_search.go +++ b/native/cmd/lich-native/dock_search.go @@ -11,6 +11,8 @@ import ( "unicode/utf8" "github.com/lichdotdev/lich/native/i18n" + + "github.com/lichdotdev/lich/native/backend" ) // searchSettle is how long typing has to pause before the checkout is read: @@ -18,19 +20,10 @@ import ( // not five searches. const searchSettle = 250 * time.Millisecond -// searchMatch and searchResult mirror internal/project's SearchMatch and -// SearchResult. -type searchMatch struct { - Path string `json:"path"` - Line int `json:"line"` - Text string `json:"text"` -} - -type searchResult struct { - Matches []searchMatch `json:"matches"` - Cut bool `json:"cut"` - TooLarge int `json:"tooLarge"` -} +type ( + searchMatch = backend.SearchMatch + searchResult = backend.SearchResult +) type searchGroup struct { path string diff --git a/native/cmd/lich-native/dock_search_view.go b/native/cmd/lich-native/dock_search_view.go index 067b81bd3..5a792df0a 100644 --- a/native/cmd/lich-native/dock_search_view.go +++ b/native/cmd/lich-native/dock_search_view.go @@ -10,8 +10,8 @@ import ( "gioui.org/op" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -55,7 +55,7 @@ func (c *codeTab) searchBody(gtx C, th *ui.Theme) D { data, loading, err := c.search.get() switch { case err != nil: - return notice(gtx, th, lichclient.ErrorText(err)) + return notice(gtx, th, backend.ErrorText(err)) case loading: return notice(gtx, th, i18n.T("dock.searchResults.searching")) } @@ -87,8 +87,7 @@ func (c *codeTab) fetchSearch(query string) { key = c.treeKey + " " + query } c.search.want(key, path, func(ctx context.Context) (searchResult, error) { - var r searchResult - return r, c.client.Call(ctx, "project.Search", &r, path, query) + return c.client.Search(ctx, path, query) }) } diff --git a/native/cmd/lich-native/duplicate.go b/native/cmd/lich-native/duplicate.go index 3b26b0ade..2f9c6858a 100644 --- a/native/cmd/lich-native/duplicate.go +++ b/native/cmd/lich-native/duplicate.go @@ -1,20 +1,12 @@ package main import ( + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/i18n" "github.com/lichdotdev/lich/native/ui" ) -// duplicateEventName is internal/terminal's duplicateEventName. -const duplicateEventName = "session-duplicate" - -// sessionDuplicate is internal/terminal's duplicateEvent: session ID just -// reported a conversation the open session HeldBy already runs, which a -// resume typed in the provider's own TUI does. -type sessionDuplicate struct { - ID string `json:"id"` - HeldBy string `json:"heldBy"` -} +type sessionDuplicate = api.SessionDuplicate // duplicateWarning is what the warning names and where switching lands. type duplicateWarning struct { diff --git a/native/cmd/lich-native/duplicate_test.go b/native/cmd/lich-native/duplicate_test.go index 5d2bbb0c3..0105089c6 100644 --- a/native/cmd/lich-native/duplicate_test.go +++ b/native/cmd/lich-native/duplicate_test.go @@ -3,14 +3,15 @@ package main import ( "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) func duplicateModel() *model { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{ - {ID: "p", Sessions: []lichclient.Session{{ID: "resumer", Label: "Claude 4"}}}, - {ID: "q", Sessions: []lichclient.Session{{ID: "holder", Label: "auth-refactor"}}}, + m.projects = []backend.Project{ + {ID: "p", Sessions: []backend.Session{{ID: "resumer", Label: "Claude 4"}}}, + {ID: "q", Sessions: []backend.Session{{ID: "holder", Label: "auth-refactor"}}}, } return m } @@ -19,7 +20,7 @@ func TestDuplicateEventRaisesTheWarningNamingBothCards(t *testing.T) { m := duplicateModel() var heard []sessionDuplicate m.duplicate = func(d sessionDuplicate) { heard = append(heard, d) } - send(m, duplicateEventName, sessionDuplicate{ID: "resumer", HeldBy: "holder"}) + send(m, api.SessionDuplicateEvent.Name, sessionDuplicate{ID: "resumer", HeldBy: "holder"}) if len(heard) != 1 { t.Fatalf("heard %v, want the one duplicate report", heard) } diff --git a/native/cmd/lich-native/exit_toast_test.go b/native/cmd/lich-native/exit_toast_test.go index 686e8b92b..5b7e39141 100644 --- a/native/cmd/lich-native/exit_toast_test.go +++ b/native/cmd/lich-native/exit_toast_test.go @@ -4,8 +4,9 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/gioterm" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -34,7 +35,7 @@ func exitToastOf(sv sessionView, e sessionExit) (ui.Toast, *[]string) { } func TestExitToastOffersTheCardsConversationBack(t *testing.T) { - sv := sessionView{Session: lichclient.Session{ID: "s1", Label: "eager-pine", Kind: "claude", ProviderSessionID: conversation}, profileName: "Sankhya"} + sv := sessionView{Session: backend.Session{ID: "s1", Label: "eager-pine", Kind: "claude", ProviderSessionID: conversation}, profileName: "Sankhya"} toast, asked := exitToastOf(sv, code(1)) if toast.ID != "exit:s1" || toast.Variant != ui.ToastError || toast.Duration != ui.ToastForever { t.Fatalf("toast %q variant %v duration %v, want exit:s1, error, forever", toast.ID, toast.Variant, toast.Duration) @@ -55,7 +56,7 @@ func TestExitToastOffersTheCardsConversationBack(t *testing.T) { } func TestExitToastOnTheDefaultProfileNamesNoProfile(t *testing.T) { - sv := sessionView{Session: lichclient.Session{ID: "s1", Label: "Erro Cassul", Kind: "codex", ProviderSessionID: conversation}} + sv := sessionView{Session: backend.Session{ID: "s1", Label: "Erro Cassul", Kind: "codex", ProviderSessionID: conversation}} toast, _ := exitToastOf(sv, code(0)) if toast.Variant != ui.ToastInfo || toast.Title != "Erro Cassul ended" || toast.Description != "Resume opens ba9884b8… again." { t.Fatalf("variant %v title %q description %q", toast.Variant, toast.Title, toast.Description) @@ -63,7 +64,7 @@ func TestExitToastOnTheDefaultProfileNamesNoProfile(t *testing.T) { } func TestExitToastWithoutAConversationOffersRestart(t *testing.T) { - for _, s := range []lichclient.Session{ + for _, s := range []backend.Session{ {ID: "s1", Label: "Shell 78", Kind: "shell", ProviderSessionID: conversation}, {ID: "s1", Label: "Shell 78", Kind: "claude"}, } { @@ -83,7 +84,7 @@ func TestExitToastWithoutAConversationOffersRestart(t *testing.T) { func TestEveryResumableProviderOffersResume(t *testing.T) { for _, kind := range resumableKinds { - sv := sessionView{Session: lichclient.Session{ID: "s1", Kind: kind, ProviderSessionID: conversation}} + sv := sessionView{Session: backend.Session{ID: "s1", Kind: kind, ProviderSessionID: conversation}} if _, _, a := toastButtons(exitToast(sv, code(0), func(string) {}, func() {})); a != "Resume" { t.Errorf("%s: action %q, want Resume", kind, a) } @@ -93,7 +94,7 @@ func TestEveryResumableProviderOffersResume(t *testing.T) { func exitedWindow(t *testing.T) *window { t.Helper() u := &window{model: newModel(nil, func() {}), terms: &terminals{byID: map[string]*termView{}, invalidate: func() {}}} - u.model.projects = []lichclient.Project{{ID: "p", Sessions: []lichclient.Session{{ID: "s1", Label: "Shell 78", Kind: "shell"}}}} + u.model.projects = []backend.Project{{ID: "p", Sessions: []backend.Session{{ID: "s1", Label: "Shell 78", Kind: "shell"}}}} vt, err := gioterm.New(80, 24, 10, 20, func([]byte) {}) if err != nil { t.Fatal(err) @@ -147,11 +148,11 @@ func TestRestartStartsTheConversationAsked(t *testing.T) { u.terms.invalidate = func() {} u.terms.byID["s1"] = &termView{id: "s1", vt: vt} u.terms.exited("s1", code(1)) - sv := sessionView{Session: lichclient.Session{ID: "s1", Kind: "claude", ProviderSessionID: conversation}, projectID: "p", path: "/repo"} + sv := sessionView{Session: backend.Session{ID: "s1", Kind: "claude", ProviderSessionID: conversation}, projectID: "p", path: "/repo"} if !u.terms.restart(u.ctx, sv, resume) { t.Fatal("restart reported a failure") } - if args := b.await(t, "terminal.Start"); !strings.HasPrefix(args, `["s1","p","/repo","claude","`+resume+`",`) { + if args := b.await(t, "terminal.Start"); !strings.HasPrefix(args, `[{"id":"s1","projectId":"p","cwd":"/repo","kind":"claude","resume":"`+resume+`",`) { t.Errorf("Start %s, want resume %q", args, resume) } if _, exited := u.terms.exitOf("s1"); exited { @@ -162,8 +163,8 @@ func TestRestartStartsTheConversationAsked(t *testing.T) { func TestAReportedConversationReachesTheCard(t *testing.T) { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Sessions: []lichclient.Session{{ID: "s1", Kind: "claude"}}}} - send(m, lichclient.SessionConversationEvent, lichclient.ConversationReported{ID: "s1", ProviderSessionID: conversation}) + m.projects = []backend.Project{{ID: "p", Sessions: []backend.Session{{ID: "s1", Kind: "claude"}}}} + send(m, api.SessionConversationEvent.Name, backend.ConversationReported{ID: "s1", ProviderSessionID: conversation}) if sv, ok := m.sessionViewOf("s1"); !ok || sv.ProviderSessionID != conversation { t.Fatalf("card names %q, want the reported conversation", sv.ProviderSessionID) } diff --git a/native/cmd/lich-native/footer.go b/native/cmd/lich-native/footer.go index 15be9744d..1a6093906 100644 --- a/native/cmd/lich-native/footer.go +++ b/native/cmd/lich-native/footer.go @@ -10,8 +10,8 @@ import ( "gioui.org/layout" "gioui.org/op" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -29,7 +29,7 @@ var ( // laid out as the user arranged it. type footer struct { ctx context.Context - client *lichclient.Client + client *backend.Client root *ui.Root invalidate func() // openPullRequest opens the Pulls screen on a pull request. @@ -63,7 +63,7 @@ type footer struct { toaster *ui.Toaster } -func newFooter(ctx context.Context, client *lichclient.Client, root *ui.Root, usage *footerUsage, quota *planQuota, invalidate func()) *footer { +func newFooter(ctx context.Context, client *backend.Client, root *ui.Root, usage *footerUsage, quota *planQuota, invalidate func()) *footer { return &footer{ ctx: ctx, client: client, root: root, invalidate: invalidate, usage: usage, quota: quota, @@ -233,7 +233,7 @@ func (f *footer) act(gtx C, in footerInput) { // file was refused for. func (f *footer) attach(in footerInput) { if err := attachFile(f.ctx, f.client, f.writePaste, in.sessionID, in.checkout, in.sandboxed, runtime.GOOS == "windows"); err != nil { - f.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: lichclient.ErrorText(err)}) + f.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: backend.ErrorText(err)}) } } diff --git a/native/cmd/lich-native/footer_attach.go b/native/cmd/lich-native/footer_attach.go index 79b6b1695..18c2b3b7f 100644 --- a/native/cmd/lich-native/footer_attach.go +++ b/native/cmd/lich-native/footer_attach.go @@ -5,7 +5,7 @@ import ( "regexp" "strings" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // attachFile is the footer's attach button. The @@ -15,7 +15,7 @@ import ( // built around the spawn directory. A cancelled dialog is no error; a failure // carries the backend's sentence, which names why a file was refused. write // puts the paste at the prompt. -func attachFile(ctx context.Context, c *lichclient.Client, write func(ctx context.Context, id, block string) error, sessionID, checkout string, sandboxed, windows bool) error { +func attachFile(ctx context.Context, c *backend.Client, write func(ctx context.Context, id, block string) error, sessionID, checkout string, sandboxed, windows bool) error { a, err := c.Attach(ctx, sessionID, checkout, sandboxed) if err != nil || a.Path == "" { return err diff --git a/native/cmd/lich-native/footer_editor.go b/native/cmd/lich-native/footer_editor.go index 052749258..1ae97c5bb 100644 --- a/native/cmd/lich-native/footer_editor.go +++ b/native/cmd/lich-native/footer_editor.go @@ -6,8 +6,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -109,7 +109,7 @@ func (f *footer) change(next footerLayout) { f.mu.Unlock() go func() { if err := f.commit(next); err != nil { - f.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("settings.footerSettings.saveFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + f.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("settings.footerSettings.saveFailed", i18n.Args{"error": backend.ErrorText(err)})}) } }() } diff --git a/native/cmd/lich-native/footer_layout.go b/native/cmd/lich-native/footer_layout.go index 5e0c4935c..2e7529900 100644 --- a/native/cmd/lich-native/footer_layout.go +++ b/native/cmd/lich-native/footer_layout.go @@ -5,7 +5,7 @@ import ( "encoding/json" "slices" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // footerItem is one control of the footer strip. @@ -173,7 +173,7 @@ func insertAt(items []string, index int, id string) []string { // loadFooterLayout reads the saved layout and resolves it against the cost // flag. The native window has no visibility switches or context toggle of its // own, so an unsaved layout seeds from defaultFooterVisibility. -func loadFooterLayout(ctx context.Context, c *lichclient.Client, cost bool) (footerLayout, error) { +func loadFooterLayout(ctx context.Context, c *backend.Client, cost bool) (footerLayout, error) { raw, err := c.GetSetting(ctx, footerLayoutKey, "") if err != nil { return footerLayout{}, err @@ -185,7 +185,7 @@ func loadFooterLayout(ctx context.Context, c *lichclient.Client, cost bool) (foo return resolveFooterLayout(stored, defaultFooterVisibility, true, cost), nil } -func saveFooterLayout(ctx context.Context, c *lichclient.Client, l footerLayout) error { +func saveFooterLayout(ctx context.Context, c *backend.Client, l footerLayout) error { data, err := json.Marshal(footerLayout{Left: nonNil(l.Left), Right: nonNil(l.Right)}) if err != nil { return err diff --git a/native/cmd/lich-native/footer_layout_test.go b/native/cmd/lich-native/footer_layout_test.go index 37850c9fe..651bf906c 100644 --- a/native/cmd/lich-native/footer_layout_test.go +++ b/native/cmd/lich-native/footer_layout_test.go @@ -13,7 +13,8 @@ import ( "sync" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // fakeSettings serves store.GetSetting and store.SetSetting from an in-memory @@ -24,10 +25,10 @@ type fakeSettings struct { failing bool } -func (f *fakeSettings) client(t *testing.T) *lichclient.Client { +func (f *fakeSettings) client(t *testing.T) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var args []string + var args []api.SetSettingOptions body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &args) f.mu.Lock() @@ -38,9 +39,9 @@ func (f *fakeSettings) client(t *testing.T) *lichclient.Client { } switch r.URL.Path { case "/rpc/store.GetSetting": - _ = json.NewEncoder(w).Encode(f.values[args[1]+"/"+args[0]]) + _ = json.NewEncoder(w).Encode(f.values[args[0].ProjectID+"/"+args[0].Key]) case "/rpc/store.SetSetting": - f.values[args[1]+"/"+args[0]] = args[2] + f.values[args[0].ProjectID+"/"+args[0].Key] = args[0].Value _ = json.NewEncoder(w).Encode(nil) default: t.Errorf("unexpected call %s", r.URL.Path) @@ -49,7 +50,7 @@ func (f *fakeSettings) client(t *testing.T) *lichclient.Client { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func (f *fakeSettings) get(key string) string { diff --git a/native/cmd/lich-native/footer_leaves.go b/native/cmd/lich-native/footer_leaves.go index 8fc330811..2f7966828 100644 --- a/native/cmd/lich-native/footer_leaves.go +++ b/native/cmd/lich-native/footer_leaves.go @@ -14,7 +14,7 @@ import ( "gioui.org/unit" "gioui.org/widget" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -233,7 +233,7 @@ func iconOnly(ic *icons.Icon) footerContent { // changesContent is the Review changes button: "0" beside the diff glyph on // a clean checkout, else the file count, a dot and the line counts. -func changesContent(git lichclient.DiffStats) footerContent { +func changesContent(git backend.DiffStats) footerContent { if git.Files == 0 { return glyphText(icDiff, "0") } diff --git a/native/cmd/lich-native/footer_plan.go b/native/cmd/lich-native/footer_plan.go index 7306495dd..39ba1272d 100644 --- a/native/cmd/lich-native/footer_plan.go +++ b/native/cmd/lich-native/footer_plan.go @@ -8,7 +8,7 @@ import ( "gioui.org/layout" "gioui.org/unit" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -28,7 +28,7 @@ var ( // ponytail: the pace mark and the Locked reading carry no tooltip of their // own here: a tooltip's content takes no hover in this window. The lock // reason is spelled out under its gauge either way. -func planDetails(th *ui.Theme, plan lichclient.QuotaPlan, now time.Time) layout.Widget { +func planDetails(th *ui.Theme, plan backend.QuotaPlan, now time.Time) layout.Widget { var rows []layout.Widget for _, g := range quotaGauges(plan, now) { rows = append(rows, quotaGaugeRow(th, g)) diff --git a/native/cmd/lich-native/footer_quota.go b/native/cmd/lich-native/footer_quota.go index 615151f69..a84894404 100644 --- a/native/cmd/lich-native/footer_quota.go +++ b/native/cmd/lich-native/footer_quota.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "fmt" "log" "math" @@ -11,8 +10,9 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // English until the native window has i18n. @@ -22,9 +22,6 @@ import ( // reaches the screen, never how often a provider is asked. const planQuotaRefresh = time.Minute -// sessionLimitEvent is terminal limitEventName: a turn a usage limit ended. -const sessionLimitEvent = "session-limit" - // planQuota shares one poll loop per key across its watchers and is the // window's only reader of plan quota. The key is the session the reading is // for, "" the machine's own login (which is Personal's), "profile:" @@ -33,7 +30,7 @@ const sessionLimitEvent = "session-limit" // its watchers: coming back to a card draws the numbers it had while the fresh // read is in flight. type planQuota struct { - fetch func(sessionID string) ([]lichclient.QuotaPlan, error) + fetch func(sessionID string) ([]backend.QuotaPlan, error) after func(time.Duration) <-chan time.Time onChange func() @@ -42,7 +39,7 @@ type planQuota struct { } type quotaEntry struct { - plans []lichclient.QuotaPlan + plans []backend.QuotaPlan watchers int stop chan struct{} // seq numbers the reads, so one started before the last watcher left @@ -63,8 +60,8 @@ func profileQuotaKey(prov profileProvider, slug string) string { // quotaReader reads the plan a planQuota key stands for. A profile's reading // is the one plan its own login has, so it comes back as a list of one. -func quotaReader(c *lichclient.Client) func(key string) ([]lichclient.QuotaPlan, error) { - return func(key string) ([]lichclient.QuotaPlan, error) { +func quotaReader(c *backend.Client) func(key string) ([]backend.QuotaPlan, error) { + return func(key string) ([]backend.QuotaPlan, error) { ctx := context.Background() for _, prov := range []profileProvider{claudeProfileProvider, codexProfileProvider} { slug, isProfile := strings.CutPrefix(key, prov.quotaPrefix) @@ -75,7 +72,7 @@ func quotaReader(c *lichclient.Client) func(key string) ([]lichclient.QuotaPlan, if err != nil { return nil, err } - return []lichclient.QuotaPlan{plan}, nil + return []backend.QuotaPlan{plan}, nil } return c.QuotaPlans(ctx, key) } @@ -83,12 +80,12 @@ func quotaReader(c *lichclient.Client) func(key string) ([]lichclient.QuotaPlan, // newPlanQuota polls with fetch every planQuotaRefresh; onChange runs, from // the poll goroutine, whenever a key's reading changed. -func newPlanQuota(fetch func(string) ([]lichclient.QuotaPlan, error), onChange func()) *planQuota { +func newPlanQuota(fetch func(string) ([]backend.QuotaPlan, error), onChange func()) *planQuota { return &planQuota{fetch: fetch, after: time.After, onChange: onChange, entries: map[string]*quotaEntry{}} } // plans is the last reading for key; nil until one lands. -func (q *planQuota) plans(key string) []lichclient.QuotaPlan { +func (q *planQuota) plans(key string) []backend.QuotaPlan { q.mu.Lock() defer q.mu.Unlock() if e := q.entries[key]; e != nil { @@ -140,23 +137,31 @@ func (q *planQuota) reread(key string) { // apply reads a session's quota again when its events say the spend moved: a // turn that ended, a usage limit it hit, or a switch of login. It is the // store listening to /events, so the code that made the change names no one. -func (q *planQuota) apply(ev lichclient.Event) { +func (q *planQuota) apply(ev backend.Event) { + var id string switch ev.Name { - case sessionLimitEvent, lichclient.SessionProfileEvent, statusEventName: - default: - return - } - var e struct { - ID string `json:"id"` - State string `json:"state"` - } - if json.Unmarshal(ev.Data, &e) != nil || e.ID == "" { - return + case api.SessionLimitEvent.Name: + e, err := backend.Decode(ev, api.SessionLimitEvent) + if err != nil { + return + } + id = e.ID + case api.SessionProfileEvent.Name: + e, err := backend.Decode(ev, api.SessionProfileEvent) + if err != nil { + return + } + id = e.ID + case api.SessionStatusEvent.Name: + e, err := backend.Decode(ev, api.SessionStatusEvent) + if err != nil || e.State != statusDone { + return + } + id = e.ID } - if ev.Name == statusEventName && e.State != statusDone { - return + if id != "" { + q.reread(id) } - q.reread(e.ID) } // invalidate drops key's reading and reads it again when something watches it: @@ -222,10 +227,10 @@ func (q *planQuota) refresh(key string, e *quotaEntry) { // sameQuota keeps a reading nobody is spending from redrawing every minute. It // compares the plans and their windows, the account included: a session moved // to another login can read the same numbers under another name. -func sameQuota(a, b []lichclient.QuotaPlan) bool { - return slices.EqualFunc(a, b, func(p, o lichclient.QuotaPlan) bool { +func sameQuota(a, b []backend.QuotaPlan) bool { + return slices.EqualFunc(a, b, func(p, o backend.QuotaPlan) bool { return p.Provider == o.Provider && p.Status == o.Status && p.Plan == o.Plan && p.Account == o.Account && - slices.EqualFunc(p.Windows, o.Windows, func(w, v lichclient.QuotaWindow) bool { + slices.EqualFunc(p.Windows, o.Windows, func(w, v backend.QuotaWindow) bool { return w.Label == v.Label && w.Percent == v.Percent && w.Seconds == v.Seconds && w.ResetsAt == v.ResetsAt }) }) @@ -233,10 +238,10 @@ func sameQuota(a, b []lichclient.QuotaPlan) bool { // planFor is usePlanQuotaFor: provider's reading, false for a provider lich // reads no plan from and while the first reading is in flight. -func planFor(plans []lichclient.QuotaPlan, provider string) (lichclient.QuotaPlan, bool) { - i := slices.IndexFunc(plans, func(p lichclient.QuotaPlan) bool { return p.Provider == provider }) +func planFor(plans []backend.QuotaPlan, provider string) (backend.QuotaPlan, bool) { + i := slices.IndexFunc(plans, func(p backend.QuotaPlan) bool { return p.Provider == provider }) if provider == "" || i < 0 { - return lichclient.QuotaPlan{}, false + return backend.QuotaPlan{}, false } return plans[i], true } @@ -244,12 +249,12 @@ func planFor(plans []lichclient.QuotaPlan, provider string) (lichclient.QuotaPla // hottestWindow is the window a single readout shows: the provider's own // active verdict when it names one, else the window closest to spent, the // first on a tie. -func hottestWindow(plan lichclient.QuotaPlan) (lichclient.QuotaWindow, bool) { - if i := slices.IndexFunc(plan.Windows, func(w lichclient.QuotaWindow) bool { return w.Active }); i >= 0 { +func hottestWindow(plan backend.QuotaPlan) (backend.QuotaWindow, bool) { + if i := slices.IndexFunc(plan.Windows, func(w backend.QuotaWindow) bool { return w.Active }); i >= 0 { return plan.Windows[i], true } if len(plan.Windows) == 0 { - return lichclient.QuotaWindow{}, false + return backend.QuotaWindow{}, false } hottest := plan.Windows[0] for _, w := range plan.Windows[1:] { @@ -262,11 +267,11 @@ func hottestWindow(plan lichclient.QuotaPlan) (lichclient.QuotaWindow, bool) { // footerWindow picks the window the strip shows: a locked window stays visible // even when another is fuller. False unless the reading is ok. -func footerWindow(plan lichclient.QuotaPlan) (lichclient.QuotaWindow, bool) { +func footerWindow(plan backend.QuotaPlan) (backend.QuotaWindow, bool) { if plan.Status != "ok" { - return lichclient.QuotaWindow{}, false + return backend.QuotaWindow{}, false } - if i := slices.IndexFunc(plan.Windows, func(w lichclient.QuotaWindow) bool { return w.LockedReason != "" }); i >= 0 { + if i := slices.IndexFunc(plan.Windows, func(w backend.QuotaWindow) bool { return w.LockedReason != "" }); i >= 0 { return plan.Windows[i], true } return hottestWindow(plan) @@ -274,7 +279,7 @@ func footerWindow(plan lichclient.QuotaPlan) (lichclient.QuotaWindow, bool) { // planReadout is the plan quota's strip text and tooltip title; the tooltip // body is quotaGauges and accountLine. -func planReadout(plan lichclient.QuotaPlan) (footerReadout, bool) { +func planReadout(plan backend.QuotaPlan) (footerReadout, bool) { w, ok := footerWindow(plan) if !ok { return footerReadout{}, false @@ -302,7 +307,7 @@ type quotaGauge struct { Tone usageTone } -func quotaGauges(plan lichclient.QuotaPlan, now time.Time) []quotaGauge { +func quotaGauges(plan backend.QuotaPlan, now time.Time) []quotaGauge { rows := make([]quotaGauge, 0, len(plan.Windows)) for _, w := range plan.Windows { g := quotaGauge{ @@ -320,7 +325,7 @@ func quotaGauges(plan lichclient.QuotaPlan, now time.Time) []quotaGauge { // accountLine is the account a gauge was read against. A token login has a // name lich may not ask for, which is worth a word rather than a gap. -func accountLine(plan lichclient.QuotaPlan) string { +func accountLine(plan backend.QuotaPlan) string { if plan.Account != "" { return plan.Account } diff --git a/native/cmd/lich-native/footer_readout_test.go b/native/cmd/lich-native/footer_readout_test.go index 17bc6a1a8..dc97f5237 100644 --- a/native/cmd/lich-native/footer_readout_test.go +++ b/native/cmd/lich-native/footer_readout_test.go @@ -14,8 +14,9 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) func TestFormatCost(t *testing.T) { @@ -171,21 +172,21 @@ func TestHandsOnDetailByRung(t *testing.T) { } } -func quotaPlan(windows ...lichclient.QuotaWindow) lichclient.QuotaPlan { - return lichclient.QuotaPlan{Provider: "claude", Name: "Claude", Status: "ok", Windows: windows} +func quotaPlan(windows ...backend.QuotaWindow) backend.QuotaPlan { + return backend.QuotaPlan{Provider: "claude", Name: "Claude", Status: "ok", Windows: windows} } func TestHottestWindow(t *testing.T) { - session := lichclient.QuotaWindow{Label: "Session", Percent: 40} - weekly := lichclient.QuotaWindow{Label: "Weekly", Percent: 80} + session := backend.QuotaWindow{Label: "Session", Percent: 40} + weekly := backend.QuotaWindow{Label: "Weekly", Percent: 80} cases := []struct { name string - plan lichclient.QuotaPlan + plan backend.QuotaPlan want string }{ {"closest to spent, whatever its position", quotaPlan(session, weekly), "Weekly"}, - {"provider's order on a tie", quotaPlan(session, lichclient.QuotaWindow{Label: "Weekly", Percent: 40}), "Session"}, - {"the active verdict wins", quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 10, Active: true}, weekly), "Session"}, + {"provider's order on a tie", quotaPlan(session, backend.QuotaWindow{Label: "Weekly", Percent: 40}), "Session"}, + {"the active verdict wins", quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 10, Active: true}, weekly), "Session"}, } for _, c := range cases { if got, _ := hottestWindow(c.plan); got.Label != c.want { @@ -199,19 +200,19 @@ func TestHottestWindow(t *testing.T) { func TestPlanReadout(t *testing.T) { plan := quotaPlan( - lichclient.QuotaWindow{Label: "Session", Seconds: 18000, Percent: 96}, - lichclient.QuotaWindow{Label: "Weekly", Seconds: 604800, Percent: 20, LockedReason: "Out of credits"}, + backend.QuotaWindow{Label: "Session", Seconds: 18000, Percent: 96}, + backend.QuotaWindow{Label: "Weekly", Seconds: 604800, Percent: 20, LockedReason: "Out of credits"}, ) plan.Plan = "Max" r, ok := planReadout(plan) if !ok || r.Text != "wk Locked" || r.Tone != toneDestructive || r.Title != "Claude · Max" { t.Errorf("a locked window must stay visible: %+v", r) } - r, _ = planReadout(quotaPlan(lichclient.QuotaWindow{Label: "Session", Seconds: 18000, Percent: 82})) + r, _ = planReadout(quotaPlan(backend.QuotaWindow{Label: "Session", Seconds: 18000, Percent: 82})) if r.Text != "5h 82%" || r.Tone != toneWait || r.Title != "Claude" { t.Errorf("got %+v", r) } - signedOut := quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 1}) + signedOut := quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 1}) signedOut.Status = "signed-out" if _, ok := planReadout(signedOut); ok { t.Error("a reading that is not ok drew a readout") @@ -219,7 +220,7 @@ func TestPlanReadout(t *testing.T) { } func TestPlanFor(t *testing.T) { - plans := []lichclient.QuotaPlan{{Provider: "codex"}, {Provider: "claude"}} + plans := []backend.QuotaPlan{{Provider: "codex"}, {Provider: "claude"}} if p, ok := planFor(plans, "claude"); !ok || p.Provider != "claude" { t.Errorf("got %+v, %v", p, ok) } @@ -233,8 +234,8 @@ func TestPlanFor(t *testing.T) { func TestQuotaGauges(t *testing.T) { now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) plan := quotaPlan( - lichclient.QuotaWindow{Label: "Session", Seconds: 18000, Percent: 42, ResetsAt: "2026-08-17T16:12:00Z"}, - lichclient.QuotaWindow{Label: "Weekly", Seconds: 604800, Percent: 85, Ahead: true, LockedReason: "x"}, + backend.QuotaWindow{Label: "Session", Seconds: 18000, Percent: 42, ResetsAt: "2026-08-17T16:12:00Z"}, + backend.QuotaWindow{Label: "Weekly", Seconds: 604800, Percent: 85, Ahead: true, LockedReason: "x"}, ) want := []quotaGauge{ {Label: "Session · 5h", Reading: "42%", Left: "4h 12m", Percent: 42}, @@ -247,12 +248,12 @@ func TestQuotaGauges(t *testing.T) { func TestAccountLine(t *testing.T) { cases := []struct { - plan lichclient.QuotaPlan + plan backend.QuotaPlan want string }{ - {lichclient.QuotaPlan{Account: "dev@example.com"}, "dev@example.com"}, - {lichclient.QuotaPlan{NoAccount: "token-login"}, "Token login"}, - {lichclient.QuotaPlan{}, ""}, + {backend.QuotaPlan{Account: "dev@example.com"}, "dev@example.com"}, + {backend.QuotaPlan{NoAccount: "token-login"}, "Token login"}, + {backend.QuotaPlan{}, ""}, } for _, c := range cases { if got := accountLine(c.plan); got != c.want { @@ -320,7 +321,7 @@ func TestComposeDroppedPaths(t *testing.T) { } // attachBackend answers drop.Attach with picked and records terminal.Write. -func attachBackend(t *testing.T, picked lichclient.Attachment, writes *[][]string) *lichclient.Client { +func attachBackend(t *testing.T, picked backend.Attachment, writes *[][]string) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var args []json.RawMessage @@ -328,15 +329,15 @@ func attachBackend(t *testing.T, picked lichclient.Attachment, writes *[][]strin _ = json.Unmarshal(body, &args) switch r.URL.Path { case "/rpc/drop.Attach": - if string(args[1]) != `"/repo"` || string(args[2]) != "true" { + var opts api.AttachDropOptions + if json.Unmarshal(args[0], &opts) != nil || opts.Root != "/repo" || !opts.Confined { t.Errorf("drop.Attach args %s", body) } _ = json.NewEncoder(w).Encode(picked) case "/rpc/terminal.Write": - var id, data string - _ = json.Unmarshal(args[0], &id) - _ = json.Unmarshal(args[1], &data) - *writes = append(*writes, []string{id, data}) + var opts api.WriteOptions + _ = json.Unmarshal(args[0], &opts) + *writes = append(*writes, []string{opts.ID, opts.Data}) _ = json.NewEncoder(w).Encode(nil) default: t.Errorf("unexpected call %s", r.URL.Path) @@ -345,12 +346,12 @@ func attachBackend(t *testing.T, picked lichclient.Attachment, writes *[][]strin t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func TestAttachFilePastesThePickedPathUnsent(t *testing.T) { var writes [][]string - c := attachBackend(t, lichclient.Attachment{Path: "/cfg/dropped/b.png", Notice: "copy of b"}, &writes) + c := attachBackend(t, backend.Attachment{Path: "/cfg/dropped/b.png", Notice: "copy of b"}, &writes) if err := attachFile(context.Background(), c, c.Write, "s1", "/repo", true, false); err != nil { t.Fatal(err) } @@ -362,7 +363,7 @@ func TestAttachFilePastesThePickedPathUnsent(t *testing.T) { func TestAttachFileCancelledWritesNothing(t *testing.T) { var writes [][]string - c := attachBackend(t, lichclient.Attachment{}, &writes) + c := attachBackend(t, backend.Attachment{}, &writes) if err := attachFile(context.Background(), c, c.Write, "s1", "/repo", true, false); err != nil || len(writes) != 0 { t.Errorf("cancelled dialog: err %v, writes %q", err, writes) } diff --git a/native/cmd/lich-native/footer_slots.go b/native/cmd/lich-native/footer_slots.go index a46db457c..87b61d23d 100644 --- a/native/cmd/lich-native/footer_slots.go +++ b/native/cmd/lich-native/footer_slots.go @@ -3,7 +3,7 @@ package main import ( "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // footerInput is everything FooterBar reads about what is on screen: the @@ -15,12 +15,12 @@ type footerInput struct { checkout string // where the session was spawned, which a cd does not move sandboxed bool host string // where the shell went that the backend cannot see into - git lichclient.DiffStats + git backend.DiffStats isGit bool // path is a git checkout git answered for - pr *lichclient.PullRequest + pr *backend.PullRequest usage sessionUsage hasUsage bool - plans []lichclient.QuotaPlan + plans []backend.QuotaPlan handsOn int64 // handsOnErr is why the hands-on figure could not be read. handsOnErr error @@ -40,7 +40,7 @@ type footerSlot struct { // Hint is the tooltip of checkout, path and model. Hint string Readout footerReadout // context, plan, cost, handsOn - Plan lichclient.QuotaPlan + Plan backend.QuotaPlan } // footerSlots is the side's items that have something to show, in order. diff --git a/native/cmd/lich-native/footer_stores_test.go b/native/cmd/lich-native/footer_stores_test.go index 65c84eb0a..5ea6ce592 100644 --- a/native/cmd/lich-native/footer_stores_test.go +++ b/native/cmd/lich-native/footer_stores_test.go @@ -14,11 +14,11 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) -func footerEvent(name, data string) lichclient.Event { - return lichclient.Event{Name: name, Data: json.RawMessage(data)} +func footerEvent(name, data string) backend.Event { + return backend.Event{Name: name, Data: json.RawMessage(data)} } const opusReport = `{"id":"s1","percent":42,"tokens":84000,"window":200000,"model":"claude-opus-4-8","effort":"high"}` @@ -44,7 +44,7 @@ func TestFooterUsageKeepsTheLastReportPerSession(t *testing.T) { } func TestFooterUsageClearsWhenTheProviderChangesOrLeaves(t *testing.T) { - clears := []lichclient.Event{ + clears := []backend.Event{ footerEvent("session-agent", `{"id":"s1","agent":"codex"}`), footerEvent("session-agent", `{"id":"s1","agent":""}`), // a respawn footerEvent("session-status", `{"id":"s1","state":"idle"}`), @@ -114,13 +114,13 @@ type quotaRig struct { changed chan struct{} mu sync.Mutex reads int - reply []lichclient.QuotaPlan + reply []backend.QuotaPlan fail error } func newQuotaRig() *quotaRig { r := "aRig{tick: make(chan time.Time), changed: make(chan struct{}, 8)} - r.q = newPlanQuota(func(string) ([]lichclient.QuotaPlan, error) { + r.q = newPlanQuota(func(string) ([]backend.QuotaPlan, error) { r.mu.Lock() defer r.mu.Unlock() r.reads++ @@ -135,7 +135,7 @@ func newQuotaRig() *quotaRig { return r } -func (r *quotaRig) set(plans []lichclient.QuotaPlan, err error) { +func (r *quotaRig) set(plans []backend.QuotaPlan, err error) { r.mu.Lock() r.reply, r.fail = plans, err r.mu.Unlock() @@ -158,7 +158,7 @@ func waitChanged(t *testing.T, ch chan struct{}) { func TestPlanQuotaReadsOnFirstWatchAndShares(t *testing.T) { r := newQuotaRig() - r.set([]lichclient.QuotaPlan{quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 10})}, nil) + r.set([]backend.QuotaPlan{quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 10})}, nil) off1 := r.q.watch("s1") off2 := r.q.watch("s1") waitChanged(t, r.changed) @@ -177,7 +177,7 @@ func TestPlanQuotaReadsOnFirstWatchAndShares(t *testing.T) { func TestPlanQuotaPublishesOnlyAChangedReading(t *testing.T) { r := newQuotaRig() - reading := []lichclient.QuotaPlan{quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 10})} + reading := []backend.QuotaPlan{quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 10})} r.set(reading, nil) defer r.q.watch("")() waitChanged(t, r.changed) @@ -189,7 +189,7 @@ func TestPlanQuotaPublishesOnlyAChangedReading(t *testing.T) { if len(r.changed) != 0 { t.Error("an unchanged reading was published") } - r.set([]lichclient.QuotaPlan{quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 11})}, nil) + r.set([]backend.QuotaPlan{quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 11})}, nil) r.tick <- time.Time{} waitChanged(t, r.changed) if got := r.q.plans(""); got[0].Windows[0].Percent != 11 { @@ -199,15 +199,15 @@ func TestPlanQuotaPublishesOnlyAChangedReading(t *testing.T) { func TestPlanQuotaRereadsAtOnceWhenTheAccountChanges(t *testing.T) { r := newQuotaRig() - personal := quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 10}) + personal := quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 10}) personal.Account = "me@example.com" - r.set([]lichclient.QuotaPlan{personal}, nil) + r.set([]backend.QuotaPlan{personal}, nil) defer r.q.watch("s1")() waitChanged(t, r.changed) work := personal work.Account = "me@work.example" - r.set([]lichclient.QuotaPlan{work}, nil) + r.set([]backend.QuotaPlan{work}, nil) r.q.reread("s1") waitChanged(t, r.changed) if got := r.q.plans("s1"); got[0].Account != "me@work.example" { @@ -217,7 +217,7 @@ func TestPlanQuotaRereadsAtOnceWhenTheAccountChanges(t *testing.T) { func TestPlanQuotaKeepsTheLastReadingWhenAReadFails(t *testing.T) { r := newQuotaRig() - r.set([]lichclient.QuotaPlan{quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 10})}, nil) + r.set([]backend.QuotaPlan{quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 10})}, nil) defer r.q.watch("s1")() waitChanged(t, r.changed) r.set(nil, errors.New("down")) @@ -230,7 +230,7 @@ func TestPlanQuotaKeepsTheLastReadingWhenAReadFails(t *testing.T) { func TestPlanQuotaStopsPollingWithNoWatcherAndKeepsTheReading(t *testing.T) { r := newQuotaRig() - r.set([]lichclient.QuotaPlan{quotaPlan()}, nil) + r.set([]backend.QuotaPlan{quotaPlan()}, nil) off := r.q.watch("s1") waitChanged(t, r.changed) off() @@ -252,8 +252,8 @@ func TestPlanQuotaStopsPollingWithNoWatcherAndKeepsTheReading(t *testing.T) { func TestPlanQuotaRereadsASessionWhoseSpendMoved(t *testing.T) { r := newQuotaRig() - reading := func(percent int) []lichclient.QuotaPlan { - return []lichclient.QuotaPlan{quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: percent})} + reading := func(percent int) []backend.QuotaPlan { + return []backend.QuotaPlan{quotaPlan(backend.QuotaWindow{Label: "Session", Percent: percent})} } r.set(reading(10), nil) defer r.q.watch("s1")() @@ -270,7 +270,7 @@ func TestPlanQuotaRereadsASessionWhoseSpendMoved(t *testing.T) { if r.readCount() != 1 { t.Fatalf("read %d times for events that spend nothing", r.readCount()) } - for _, ev := range []lichclient.Event{ + for _, ev := range []backend.Event{ footerEvent("session-status", `{"id":"s1","state":"done"}`), footerEvent("session-limit", `{"id":"s1","window":"session","resetsAt":0}`), } { @@ -289,9 +289,9 @@ func TestPlanQuotaRereadsASessionWhoseSpendMoved(t *testing.T) { func TestPlanQuotaInvalidateDropsTheReadingAndReadsAgain(t *testing.T) { r := newQuotaRig() - personal := quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 10}) + personal := quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 10}) personal.Account = "me@example.com" - r.set([]lichclient.QuotaPlan{personal}, nil) + r.set([]backend.QuotaPlan{personal}, nil) defer r.q.watch(profileQuotaKey(claudeProfileProvider, "work"))() waitChanged(t, r.changed) @@ -327,15 +327,15 @@ func TestQuotaReaderRoutesAProfileKeyToItsOwnPlan(t *testing.T) { body, _ := io.ReadAll(r.Body) calls = append(calls, strings.TrimPrefix(r.URL.Path, "/rpc/")+string(body)) if strings.HasSuffix(r.URL.Path, "quota.ProfilePlan") { - _ = json.NewEncoder(w).Encode(quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 7})) + _ = json.NewEncoder(w).Encode(quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 7})) return } - _ = json.NewEncoder(w).Encode([]lichclient.QuotaPlan{quotaPlan()}) + _ = json.NewEncoder(w).Encode([]backend.QuotaPlan{quotaPlan()}) })) t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - read := quotaReader(lichclient.New(lichclient.Runtime{Port: port, Token: "tok"})) + read := quotaReader(backend.New(backend.Runtime{Port: port, Token: "tok"})) plans, err := read(profileQuotaKey(claudeProfileProvider, "work")) if err != nil || len(plans) != 1 || plans[0].Windows[0].Percent != 7 { @@ -352,8 +352,8 @@ func TestQuotaReaderRoutesAProfileKeyToItsOwnPlan(t *testing.T) { func TestProfileBookWatchesTheQuotedPlansOnlyWhileThereIsAChoice(t *testing.T) { r := newQuotaRig() b := newProfileBook(claudeProfileProvider, nil, func() {}, r.q) - one := []lichclient.Profile{{Slug: personalProfile, LoggedIn: true}, {Slug: "work"}} - two := []lichclient.Profile{{Slug: personalProfile, LoggedIn: true}, {Slug: "work", LoggedIn: true}} + one := []backend.Profile{{Slug: personalProfile, LoggedIn: true}, {Slug: "work"}} + two := []backend.Profile{{Slug: personalProfile, LoggedIn: true}, {Slug: "work", LoggedIn: true}} watched := func() int { r.q.mu.Lock() defer r.q.mu.Unlock() diff --git a/native/cmd/lich-native/footer_test.go b/native/cmd/lich-native/footer_test.go index 2f003415f..0fb5a2999 100644 --- a/native/cmd/lich-native/footer_test.go +++ b/native/cmd/lich-native/footer_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -30,10 +30,10 @@ func allItems() []string { func busySession() footerInput { return footerInput{ sessionID: "s1", provider: "claude", path: "/home/me/repo", checkout: "/home/me/repo", - git: lichclient.DiffStats{Branch: "main", Files: 2}, isGit: true, - pr: &lichclient.PullRequest{Number: 7, URL: "https://example.com/pr/7"}, + git: backend.DiffStats{Branch: "main", Files: 2}, isGit: true, + pr: &backend.PullRequest{Number: 7, URL: "https://example.com/pr/7"}, usage: sessionUsage{Percent: 42, Tokens: 1000, Window: 200000, Model: "claude-opus-4-8", CostUSD: cost(0.5)}, hasUsage: true, - plans: []lichclient.QuotaPlan{quotaPlan(lichclient.QuotaWindow{Label: "Session", Seconds: 5 * hour, Percent: 28})}, + plans: []backend.QuotaPlan{quotaPlan(backend.QuotaWindow{Label: "Session", Seconds: 5 * hour, Percent: 28})}, handsOn: 600, cost: true, now: time.Date(2026, 10, 11, 14, 30, 0, 0, time.UTC), } } diff --git a/native/cmd/lich-native/footer_usage.go b/native/cmd/lich-native/footer_usage.go index ed1efbc72..5138ca868 100644 --- a/native/cmd/lich-native/footer_usage.go +++ b/native/cmd/lich-native/footer_usage.go @@ -6,7 +6,8 @@ import ( "slices" "sync" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // costMisses are the named absences of a cost figure this build has a @@ -67,31 +68,32 @@ func (f *footerUsage) get(id string) (sessionUsage, bool) { // session-status events that void the prior provider's report. It reports // whether ev was one of the three; session-agent and session-status are not // consumed, so the caller still hands them to the card stores. -func (f *footerUsage) apply(ev lichclient.Event) (bool, error) { - var e struct { - ID string `json:"id"` - Agent *string `json:"agent"` - State string `json:"state"` - } - if ev.Name == "session-usage" { +func (f *footerUsage) apply(ev backend.Event) (bool, error) { + var id string + switch ev.Name { + case api.SessionUsageEvent.Name: return true, f.applyUsage(ev.Data) - } - if ev.Name != "session-agent" && ev.Name != statusEventName { + case api.SessionAgentEvent.Name: + // Every spawn announces its agent, "" included, so any agent report + // means the prior provider's numbers no longer describe this session. + e, err := backend.Decode(ev, api.SessionAgentEvent) + if err != nil { + return true, err + } + id = e.ID + case api.SessionStatusEvent.Name: + e, err := backend.Decode(ev, api.SessionStatusEvent) + if err != nil || e.State != "idle" { + return true, err + } + id = e.ID + default: return false, nil } - if err := json.Unmarshal(ev.Data, &e); err != nil { - return true, err - } - // Every spawn announces its agent, "" included, so any agent report means - // the prior provider's numbers no longer describe this session. - agentChanged := ev.Name == "session-agent" && e.Agent != nil - providerLeft := ev.Name == statusEventName && e.State == "idle" - if agentChanged || providerLeft { - f.mu.Lock() - delete(f.usage, e.ID) - f.heard[e.ID] = true - f.mu.Unlock() - } + f.mu.Lock() + delete(f.usage, id) + f.heard[id] = true + f.mu.Unlock() return true, nil } diff --git a/native/cmd/lich-native/fork.go b/native/cmd/lich-native/fork.go index dff07908b..36644371d 100644 --- a/native/cmd/lich-native/fork.go +++ b/native/cmd/lich-native/fork.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // forkableKinds are providers.SupportsFork: the providers whose own CLI @@ -55,12 +55,12 @@ type forkSource struct { path string } -func forkSourceOf(s lichclient.Session) forkSource { +func forkSourceOf(s backend.Session) forkSource { return forkSource{id: s.ID, label: s.Label, kind: s.Kind, conversation: s.ProviderSessionID, folder: s.Folder, path: s.Path} } // labelsOf are the names a project's open sessions go by. -func labelsOf(p lichclient.Project) []string { +func labelsOf(p backend.Project) []string { labels := make([]string, len(p.Sessions)) for i, s := range p.Sessions { labels[i] = s.Label diff --git a/native/cmd/lich-native/fork_actions.go b/native/cmd/lich-native/fork_actions.go index 0b7eaa55c..08a4735f7 100644 --- a/native/cmd/lich-native/fork_actions.go +++ b/native/cmd/lich-native/fork_actions.go @@ -4,19 +4,19 @@ import ( "crypto/rand" "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) // forkSource reads the open session id as a fork's source, with a copy of // its project. -func (m *model) forkSource(id string) (lichclient.Project, forkSource, bool) { +func (m *model) forkSource(id string) (backend.Project, forkSource, bool) { m.mu.Lock() defer m.mu.Unlock() p, i := m.sessionAt(id) if p == nil { - return lichclient.Project{}, forkSource{}, false + return backend.Project{}, forkSource{}, false } return *p, forkSourceOf(p.Sessions[i]), true } @@ -33,7 +33,7 @@ func (u *window) forkHere(id string) { return } if _, err := u.addForkSession(p, src, src.path); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.fork.failed", i18n.Args{"name": src.label, "error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.fork.failed", i18n.Args{"name": src.label, "error": backend.ErrorText(err)})}) } } @@ -46,7 +46,7 @@ func (e forkPruned) Error() string { return e.text } // conversationLeft asks the provider for src's conversation before a fork // is made of it: one it pruned would die in the new card with the // provider's own error, after the card and any worktree already exist. -func (u *window) conversationLeft(p lichclient.Project, src forkSource) error { +func (u *window) conversationLeft(p backend.Project, src forkSource) error { cwd := src.path if cwd == "" { cwd = p.Path @@ -64,7 +64,7 @@ func (u *window) conversationLeft(p lichclient.Project, src forkSource) error { // addForkSession files a fork of src at path (stored as checkoutPath // stores it), named after src, right under it in the drag order, and puts it // on screen; its first start branches src's conversation. -func (u *window) addForkSession(p lichclient.Project, src forkSource, path string) (string, error) { +func (u *window) addForkSession(p backend.Project, src forkSource, path string) (string, error) { id := strings.ToLower(rand.Text()) label := forkLabel(src.label, labelsOf(p)) if err := u.client.AddSessionFrom(u.ctx, p.ID, id, label, src.kind, checkoutPath(path, p.Path), p.NextSeq+1, src.id, src.label, src.folder); err != nil { diff --git a/native/cmd/lich-native/gates_click_test.go b/native/cmd/lich-native/gates_click_test.go index fc1aaf1b0..4b04a0092 100644 --- a/native/cmd/lich-native/gates_click_test.go +++ b/native/cmd/lich-native/gates_click_test.go @@ -23,7 +23,8 @@ import ( "gioui.org/widget" "github.com/coder/websocket" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -32,7 +33,7 @@ import ( // a window can attach to it and lose it again. type gateBackend struct { calls chan string - state []lichclient.Project + state []backend.Project answer map[string]any srv *httptest.Server @@ -51,7 +52,7 @@ type gateBackend struct { func newGateBackend(t *testing.T) *gateBackend { t.Helper() b := &gateBackend{calls: make(chan string, 64), answer: map[string]any{}, dropEvents: make(chan struct{}), - state: []lichclient.Project{{ID: "p", Name: "repo", Path: "/repo"}}} + state: []backend.Project{{ID: "p", Name: "repo", Path: "/repo"}}} b.srv = httptest.NewServer(http.HandlerFunc(b.serve)) t.Cleanup(b.srv.Close) return b @@ -209,12 +210,12 @@ func TestGitMissingNotNowClosesTheDialog(t *testing.T) { func TestGitMissingCheckAgainRereadsThePathThenVerifies(t *testing.T) { u, b := newGateWindow(t) - b.answer["providers.Verify"] = lichclient.BinaryCheck{Path: "/usr/bin/git", Status: lichclient.CheckOK} + b.answer["providers.Verify"] = backend.BinaryCheck{Path: "/usr/bin/git", Status: backend.CheckOK} d := gitDialog(u) d.open() d.press(t, &u.gitMissing.again) b.await(t, "providers.RefreshPath") - if args := b.await(t, "providers.Verify"); args != `["git"]` { + if args := b.await(t, "providers.Verify"); args != `[{"bin":"git"}]` { t.Errorf("verified %s, want git", args) } for u.gitMissing.checking.Load() { @@ -230,7 +231,7 @@ func TestGitMissingInstallOpensTheDownloadPage(t *testing.T) { d := gitDialog(u) d.open() d.press(t, &u.gitMissing.install) - if args := b.await(t, "system.OpenExternal"); args != `["`+vcsGitURL+`"]` { + if args := b.await(t, "system.OpenExternal"); args != `[{"url":"`+vcsGitURL+`"}]` { t.Errorf("opened %s, want %s", args, vcsGitURL) } if u.gitMissing.missing.Load() { @@ -240,7 +241,7 @@ func TestGitMissingInstallOpensTheDownloadPage(t *testing.T) { func pluginDialog(u *window) *dialogDriver { u.plugin.started = true - u.plugin.offer = []lichclient.PluginStatus{{Provider: "claude", Name: "Claude Code"}, {Provider: "codex", Name: "Codex"}} + u.plugin.offer = []backend.PluginStatus{{Provider: "claude", Name: "Claude Code"}, {Provider: "codex", Name: "Codex"}} return &dialogDriver{now: time.Unix(0, 0), draw: func(gtx C) { u.layoutPluginGate(gtx, u.theme) }} } @@ -255,7 +256,7 @@ func TestPluginGateDontAskRemembersAndCloses(t *testing.T) { d := pluginDialog(u) d.open() d.press(t, &u.plugin.dontAsk) - if args := b.await(t, "store.SetSetting"); args != `["`+pluginInstallDismissedKey+`","","1"]` { + if args := b.await(t, "store.SetSetting"); args != `[{"key":"`+pluginInstallDismissedKey+`","projectId":"","value":"1"}]` { t.Errorf("wrote %s, want the install dismissed", args) } if pluginOffered(u) { @@ -283,7 +284,7 @@ func TestPluginGateInstallInstallsEveryTickedCLI(t *testing.T) { d := pluginDialog(u) d.open() d.press(t, &u.plugin.install) - for _, want := range []string{`["claude"]`, `["codex"]`} { + for _, want := range []string{`[{"provider":"claude"}]`, `[{"provider":"codex"}]`} { if args := b.await(t, "agentplugin.Install"); args != want { t.Errorf("installed %s, want %s", args, want) } @@ -295,42 +296,42 @@ func TestPluginGateInstallInstallsEveryTickedCLI(t *testing.T) { func TestUpdateToastLaterRemembersTheRelease(t *testing.T) { u, b := newGateWindow(t) - toast := u.appUpdateToast(lichclient.AppUpdateStatus{UpdateAvailable: true, LatestVersion: "1.4.0", ReleaseURL: "https://r"}) + toast := u.appUpdateToast(backend.AppUpdateStatus{UpdateAvailable: true, LatestVersion: "1.4.0", ReleaseURL: "https://r"}) toast.Cancel.OnClick() - if args := b.await(t, "store.SetSetting"); args != `["`+appUpdateDismissedKey+`","","1.4.0"]` { + if args := b.await(t, "store.SetSetting"); args != `[{"key":"`+appUpdateDismissedKey+`","projectId":"","value":"1.4.0"}]` { t.Errorf("wrote %s, want 1.4.0 dismissed", args) } } func TestUpdateToastViewReleaseOpensItsPage(t *testing.T) { u, b := newGateWindow(t) - toast := u.appUpdateToast(lichclient.AppUpdateStatus{LatestVersion: "1.4.0", ReleaseURL: "https://r", InstallCommand: "yay -S lich"}) + toast := u.appUpdateToast(backend.AppUpdateStatus{LatestVersion: "1.4.0", ReleaseURL: "https://r", InstallCommand: "yay -S lich"}) toast.Secondary.OnClick() - if args := b.await(t, "system.OpenExternal"); args != `["https://r"]` { + if args := b.await(t, "system.OpenExternal"); args != `[{"url":"https://r"}]` { t.Errorf("opened %s, want the release page", args) } } func TestUpdateToastInstallTypesTheCommandIntoANewShell(t *testing.T) { u, b := newGateWindow(t) - toast := u.appUpdateToast(lichclient.AppUpdateStatus{LatestVersion: "1.4.0", ReleaseURL: "https://r", InstallCommand: "yay -S lich"}) + toast := u.appUpdateToast(backend.AppUpdateStatus{LatestVersion: "1.4.0", ReleaseURL: "https://r", InstallCommand: "yay -S lich"}) toast.Action.OnClick() args := b.await(t, "store.AddSessionInFolder") - var call []any - if err := json.Unmarshal([]byte(args), &call); err != nil || call[0] != "p" || call[3] != "shell" { + var call []api.AddSessionInFolderOptions + if err := json.Unmarshal([]byte(args), &call); err != nil || call[0].ProjectID != "p" || call[0].Kind != "shell" { t.Fatalf("added %s, want a shell in the project in view", args) } b.await(t, "store.SetActiveSession") u.terms.mu.Lock() defer u.terms.mu.Unlock() - if typed := u.terms.typeahead[call[1].(string)]; typed != "yay -S lich" { + if typed := u.terms.typeahead[call[0].SessionID]; typed != "yay -S lich" { t.Errorf("queued %q for the shell, want the install command", typed) } } func TestUpdateToastUpdateAndInstallRunsTheInstaller(t *testing.T) { u, b := newGateWindow(t) - toast := u.appUpdateToast(lichclient.AppUpdateStatus{LatestVersion: "1.4.0", CanSelfApply: true}) + toast := u.appUpdateToast(backend.AppUpdateStatus{LatestVersion: "1.4.0", CanSelfApply: true}) toast.Action.OnClick() b.await(t, "appupdate.Apply") } @@ -338,13 +339,13 @@ func TestUpdateToastUpdateAndInstallRunsTheInstaller(t *testing.T) { func TestPluginUpdateToastButtons(t *testing.T) { u, b := newGateWindow(t) toast := u.pluginUpdateToast(pluginGateAction{kind: pluginActionUpdate, version: "0.9.0", - providers: []lichclient.PluginStatus{{Provider: "claude"}}}) + providers: []backend.PluginStatus{{Provider: "claude"}}}) toast.Action.OnClick() - if args := b.await(t, "agentplugin.Update"); args != `["claude"]` { + if args := b.await(t, "agentplugin.Update"); args != `[{"provider":"claude"}]` { t.Errorf("updated %s, want claude", args) } toast.Cancel.OnClick() - if args := b.await(t, "store.SetSetting"); args != `["`+pluginUpdateDismissedKey+`","","0.9.0"]` { + if args := b.await(t, "store.SetSetting"); args != `[{"key":"`+pluginUpdateDismissedKey+`","projectId":"","value":"0.9.0"}]` { t.Errorf("wrote %s, want 0.9.0 dismissed", args) } } @@ -365,7 +366,7 @@ func TestPluginGateUntickedCLIIsLeftOut(t *testing.T) { d.open() d.press(t, u.plugin.rows["claude"]) d.press(t, &u.plugin.install) - if args := b.await(t, "agentplugin.Install"); args != `["codex"]` { + if args := b.await(t, "agentplugin.Install"); args != `[{"provider":"codex"}]` { t.Errorf("installed %s, want only the CLI still ticked", args) } } diff --git a/native/cmd/lich-native/gitstate.go b/native/cmd/lich-native/gitstate.go index 810c02f15..fb7593482 100644 --- a/native/cmd/lich-native/gitstate.go +++ b/native/cmd/lich-native/gitstate.go @@ -6,7 +6,7 @@ import ( "slices" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // baseStanding is what a card draws of a base status: how many files would @@ -17,14 +17,14 @@ type baseStanding struct { count int } -func sameBase(a, b *lichclient.BaseStatus) bool { +func sameBase(a, b *backend.BaseStatus) bool { if a == nil || b == nil { return a == b } return a.Base == b.Base && a.Behind == b.Behind && slices.Equal(a.Conflicts, b.Conflicts) } -func standingOf(b *lichclient.BaseStatus) baseStanding { +func standingOf(b *backend.BaseStatus) baseStanding { if b == nil { return baseStanding{} } @@ -44,7 +44,7 @@ const prRecheck = time.Minute // and is dropped with a new branch. type prLookup struct { branch, head string - pr *lichclient.PullRequest + pr *backend.PullRequest asked time.Time } diff --git a/native/cmd/lich-native/gitstate_test.go b/native/cmd/lich-native/gitstate_test.go index 95bfc67d0..31c5a286a 100644 --- a/native/cmd/lich-native/gitstate_test.go +++ b/native/cmd/lich-native/gitstate_test.go @@ -11,18 +11,19 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) func TestStandingOf(t *testing.T) { cases := []struct { - in *lichclient.BaseStatus + in *backend.BaseStatus want baseStanding }{ {nil, baseStanding{}}, - {&lichclient.BaseStatus{Behind: 0}, baseStanding{}}, - {&lichclient.BaseStatus{Behind: 3}, baseStanding{count: 3}}, - {&lichclient.BaseStatus{Behind: 3, Conflicts: []string{"a", "b"}}, baseStanding{conflict: true, count: 2}}, + {&backend.BaseStatus{Behind: 0}, baseStanding{}}, + {&backend.BaseStatus{Behind: 3}, baseStanding{count: 3}}, + {&backend.BaseStatus{Behind: 3, Conflicts: []string{"a", "b"}}, baseStanding{conflict: true, count: 2}}, } for _, c := range cases { if got := standingOf(c.in); got != c.want { @@ -37,9 +38,9 @@ func TestStandingOf(t *testing.T) { type fakeForge struct { mu sync.Mutex branch, head string - prs map[string]*lichclient.PullRequest // head -> open PR - hold chan struct{} // while set, gh does not answer - answered int // pull request lookups served + prs map[string]*backend.PullRequest // head -> open PR + hold chan struct{} // while set, gh does not answer + answered int // pull request lookups served } func (f *fakeForge) set(branch, head string) { @@ -48,7 +49,7 @@ func (f *fakeForge) set(branch, head string) { f.branch, f.head = branch, head } -func (f *fakeForge) client(t *testing.T) *lichclient.Client { +func (f *fakeForge) client(t *testing.T) *backend.Client { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() hold := f.hold @@ -60,9 +61,9 @@ func (f *fakeForge) client(t *testing.T) *lichclient.Client { defer f.mu.Unlock() switch r.URL.Path { case "/rpc/project.Diff": - _ = json.NewEncoder(w).Encode(lichclient.DiffStats{Branch: f.branch, Head: f.head}) + _ = json.NewEncoder(w).Encode(backend.DiffStats{Branch: f.branch, Head: f.head}) case "/rpc/project.BaseStatus": - _ = json.NewEncoder(w).Encode(lichclient.BaseStatus{Behind: 2}) + _ = json.NewEncoder(w).Encode(backend.BaseStatus{Behind: 2}) case "/rpc/project.PullRequest": _ = json.NewEncoder(w).Encode(f.prs[f.head]) f.answered++ @@ -73,7 +74,7 @@ func (f *fakeForge) client(t *testing.T) *lichclient.Client { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } // prOnCard refreshes git and waits for the card's PR badge to read want. @@ -103,7 +104,7 @@ func awaitPROnCard(t *testing.T, m *model, want int) { } func TestCardFollowsThePullRequestOfItsBranch(t *testing.T) { - forge := &fakeForge{prs: map[string]*lichclient.PullRequest{ + forge := &fakeForge{prs: map[string]*backend.PullRequest{ "c2": {Number: 7}, "c4": {Number: 9}, }} @@ -131,7 +132,7 @@ func TestCardFollowsThePullRequestOfItsBranch(t *testing.T) { forge.mu.Unlock() prOnCard(t, m, 0) // c3 has no open PR: merged - forge.prs["c3"] = &lichclient.PullRequest{Number: 8} + forge.prs["c3"] = &backend.PullRequest{Number: 8} m.mu.Lock() m.stalePullRequests() // the window came back: opened in the browser m.mu.Unlock() @@ -155,7 +156,7 @@ func TestCardFollowsThePullRequestOfItsBranch(t *testing.T) { // gh pr create moves neither branch nor head, so the turn that ran it ending is // what makes the card ask again. func TestCardFindsThePullRequestItsTurnOpened(t *testing.T) { - forge := &fakeForge{prs: map[string]*lichclient.PullRequest{}} + forge := &fakeForge{prs: map[string]*backend.PullRequest{}} forge.set("feat", "c1") m := newTestModel(forge.client(t)) prOnCard(t, m, 0) @@ -172,9 +173,9 @@ func TestCardFindsThePullRequestItsTurnOpened(t *testing.T) { } forge.mu.Lock() - forge.prs["c1"] = &lichclient.PullRequest{Number: 5} + forge.prs["c1"] = &backend.PullRequest{Number: 5} forge.mu.Unlock() - send(m, statusEventName, statusEvent{ID: "s1", State: statusBusy}) - send(m, statusEventName, statusEvent{ID: "s1", State: statusDone}) + send(m, api.SessionStatusEvent.Name, statusEvent{ID: "s1", State: statusBusy}) + send(m, api.SessionStatusEvent.Name, statusEvent{ID: "s1", State: statusDone}) awaitPROnCard(t, m, 5) } diff --git a/native/cmd/lich-native/home_test.go b/native/cmd/lich-native/home_test.go index 2eadd68a5..3cf90dfa7 100644 --- a/native/cmd/lich-native/home_test.go +++ b/native/cmd/lich-native/home_test.go @@ -4,15 +4,15 @@ import ( "context" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func homeModel() *model { m := newModel(nil, func() {}) m.homeID = "home" - m.projects = []lichclient.Project{ - {ID: "home", Name: "me", Path: "/home/me", ActiveSessionID: "h1", Sessions: []lichclient.Session{{ID: "h1"}}}, - {ID: "a", Name: "lich", Path: "/src/lich", ActiveSessionID: "a1", Sessions: []lichclient.Session{{ID: "a1"}}}, + m.projects = []backend.Project{ + {ID: "home", Name: "me", Path: "/home/me", ActiveSessionID: "h1", Sessions: []backend.Session{{ID: "h1"}}}, + {ID: "a", Name: "lich", Path: "/src/lich", ActiveSessionID: "a1", Sessions: []backend.Session{{ID: "a1"}}}, } m.activeProject, m.active = "a", "a1" return m @@ -48,7 +48,7 @@ func TestHomeCannotBeClosed(t *testing.T) { } func TestAFreshWorkspaceOpensOnHomeWithAShell(t *testing.T) { - var ps []lichclient.Project + var ps []backend.Project m := newModel(fakeState(t, &ps, ""), func() {}) if err := m.reload(context.Background()); err != nil { t.Fatal(err) diff --git a/native/cmd/lich-native/i18n_shell_test.go b/native/cmd/lich-native/i18n_shell_test.go index 3b6d6dfdf..db9b361ed 100644 --- a/native/cmd/lich-native/i18n_shell_test.go +++ b/native/cmd/lich-native/i18n_shell_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // inPortuguese puts the interface in pt-BR for the test and puts English, and @@ -132,7 +132,7 @@ func TestShellMessagesWithPlaceholdersAndPluralsInPortuguese(t *testing.T) { if got := notifySummary(statusWaiting, "Claude 3"); got != "Claude 3 precisa da sua atenção" { t.Errorf("needs input: %q", got) } - if got := updateFailureText(&lichclient.AppUpdateProgress{Phase: lichclient.UpdatePhaseDownload, Received: 5, Total: 10}, "offline"); got != "Falha no download em 50%: offline" { + if got := updateFailureText(&backend.AppUpdateProgress{Phase: backend.UpdatePhaseDownload, Received: 5, Total: 10}, "offline"); got != "Falha no download em 50%: offline" { t.Errorf("download failure: %q", got) } if got := skippedDrop("a.txt", "sem permissão"); got != "a.txt (sem permissão)" { @@ -150,7 +150,7 @@ func TestShellMessagesWithPlaceholdersAndPluralsInPortuguese(t *testing.T) { } func TestPatchNotesGroupsAreNamedInTheInterfaceLanguage(t *testing.T) { - groups := []lichclient.PatchNotesGroup{{Label: "Added", Items: []string{"a"}}, {Label: "Fixed", Items: []string{"a", "b"}}} + groups := []backend.PatchNotesGroup{{Label: "Added", Items: []string{"a"}}, {Label: "Fixed", Items: []string{"a", "b"}}} if got := notesCounts(groups); got != "1 added"+notesCountSep+"2 fixed" { t.Errorf("English counts %q", got) } diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 13595577b..85ade52ec 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -32,11 +32,11 @@ import ( "gioui.org/widget/material" "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/fonts" "github.com/lichdotdev/lich/native/gioterm" "github.com/lichdotdev/lich/native/i18n" "github.com/lichdotdev/lich/native/internal/shot" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -98,7 +98,7 @@ func windowClass(getenv func(string) string) string { type window struct { ctx context.Context - client *lichclient.Client + client *backend.Client // uiFonts is what the interface's shaper was last built from. uiFonts *interfaceFonts model *model @@ -181,17 +181,17 @@ type window struct { // backendRuntime is the backend to talk to: the -runtime file when given, // else the one a launching `lich native` put in the environment. -func backendRuntime() (lichclient.Runtime, error) { +func backendRuntime() (backend.Runtime, error) { if *flagRuntime != "" { - return lichclient.ReadRuntime(*flagRuntime) + return backend.ReadRuntime(*flagRuntime) } - return lichclient.RuntimeFromEnv(os.Getenv) + return backend.RuntimeFromEnv(os.Getenv) } -func run(w *app.Window, rt lichclient.Runtime) error { +func run(w *app.Window, rt backend.Runtime) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - client := lichclient.New(rt) + client := backend.New(rt) faces, err := gioterm.Faces(*flagFont) if err != nil { return err @@ -363,7 +363,7 @@ func (u *window) connect() error { // layouts and says so; the next split stores over it. if err := u.model.loadLayouts(u.ctx); err != nil { u.logErr("load layouts", err) - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("layouts.load.failed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("layouts.load.failed", i18n.Args{"error": backend.ErrorText(err)})}) } if _, ok := u.model.project(); !ok && *flagProject != "" { path, err := filepath.Abs(*flagProject) @@ -867,9 +867,9 @@ func (u *window) apply(act sidebarAction) { // renameSession asks the backend for a new label. The card takes it from the // session-title event the backend emits once the row is written, so a rename // that fails changes nothing on screen and says why. -func renameSession(ctx context.Context, c *lichclient.Client, show func(ui.Toast) string, id, label string) { +func renameSession(ctx context.Context, c *backend.Client, show func(ui.Toast) string, id, label string) { if err := c.RenameSession(ctx, id, label); err != nil { - show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.main.renameFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.main.renameFailed", i18n.Args{"error": backend.ErrorText(err)})}) } } @@ -883,7 +883,7 @@ func (u *window) applyOpenIn(act sidebarAction) { go func() { command, err := u.client.OpenFolderInEditor(u.ctx, dir) if err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.sessionCard.openCheckoutFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.sessionCard.openCheckoutFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } // A terminal editor runs in a shell at the directory. @@ -895,7 +895,7 @@ func (u *window) applyOpenIn(act sidebarAction) { if dir := act.folderAt; dir != "" { go func() { if err := u.client.OpenFolder(u.ctx, dir); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.sessionCard.openFolderFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.sessionCard.openFolderFailed", i18n.Args{"error": backend.ErrorText(err)})}) } }() } diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index c748957b4..b7d851801 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -10,7 +10,8 @@ import ( "sync" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // gitPoll is how often each shown path's branch and changes are re-read. The @@ -26,11 +27,11 @@ var projectScreen = regexp.MustCompile(`^/projects/([^/]+)(?:/.*)?$`) // model is the backend state the window draws, fed by LoadState, the /events // stream and a git poll. Every method is safe from any goroutine. type model struct { - client *lichclient.Client + client *backend.Client invalidate func() mu sync.Mutex - projects []lichclient.Project // open projects, in the user's order + projects []backend.Project // open projects, in the user's order // pendingOrder is a tab drag the backend has not confirmed: the strip // shows it, the store's order does not hold it. projects-reordered // settles it, a refused reorder drops it. @@ -38,15 +39,15 @@ type model struct { status *statusStore usage *footerUsage quota *planQuota - cwd map[string]sessionCwd // session id -> last session-cwd report - agent map[string]string // session id -> provider CLI live in its PTY - sandbox map[string]sandboxEvent // session id -> how its spawn was confined - relay map[string]sessionRelay // session id -> request open with another - git map[string]lichclient.DiffStats // shown path -> its branch and changes - base map[string]*lichclient.BaseStatus // shown path -> where it stands against its base - prs map[string]prLookup // shown path -> its open pull request - activeProject string // project id on screen - home bool // Home is on screen instead + cwd map[string]sessionCwd // session id -> last session-cwd report + agent map[string]string // session id -> provider CLI live in its PTY + sandbox map[string]sandboxEvent // session id -> how its spawn was confined + relay map[string]sessionRelay // session id -> request open with another + git map[string]backend.DiffStats // shown path -> its branch and changes + base map[string]*backend.BaseStatus // shown path -> where it stands against its base + prs map[string]prLookup // shown path -> its open pull request + activeProject string // project id on screen + home bool // Home is on screen instead // homeID is the project rooted at the home directory, which Home shows: // pinned out of the strip and never closed. homeID string @@ -69,7 +70,7 @@ type model struct { seen func(id string) // loginProgress takes each step of a profile login, of the provider kind // names. - loginProgress func(kind string, step lichclient.ProfileLogin) + loginProgress func(kind string, step backend.ProfileLogin) // diffTouched hears the checkout of a session that likely changed files, // for the Review tab's diff, which is read apart from the git store. diffTouched func(checkout string) @@ -85,22 +86,22 @@ type model struct { projectClosed func(closedProject) // pluginProfiles hears a pass that put Personal's lich plugin into the // other profiles of a provider. - pluginProfiles func(lichclient.PluginProfiles) + pluginProfiles func(backend.PluginProfiles) // prChanged hears a pull request write land (PRChangedEvent). prChanged func() // themeSelection hears the stored theme selection change // (ThemeSelectionEvent). - themeSelection func(lichclient.ThemeSelection) + themeSelection func(backend.ThemeSelection) // exited hears a session's process end. exited func(id string, e sessionExit) // duplicate hears a session report a conversation another open card - // runs (duplicateEventName). + // runs (api.SessionDuplicateEvent). duplicate func(sessionDuplicate) // pluginIncompatible hears a session report from a lich plugin outside // the range this lich speaks (PluginIncompatibleEvent). pluginIncompatible func() // appUpdateProgress hears a step of an update being applied. - appUpdateProgress func(lichclient.AppUpdateProgress) + appUpdateProgress func(backend.AppUpdateProgress) // closed is the closed projects, which the menu and the palette read. closed *closedProjects @@ -110,19 +111,13 @@ type model struct { shownLayout string } -// sessionCwd is a session-cwd payload: where the session's shell is, or the -// host (tmux, ssh, ...) it went into that the backend cannot see through. -type sessionCwd struct { - ID string `json:"id"` - Cwd string `json:"cwd"` - Host string `json:"host"` -} +type sessionCwd = api.SessionCwd -func newModel(client *lichclient.Client, invalidate func()) *model { +func newModel(client *backend.Client, invalidate func()) *model { quota := newPlanQuota(quotaReader(client), invalidate) return &model{client: client, invalidate: invalidate, status: newStatusStore(), usage: newFooterUsage(), quota: quota, cwd: map[string]sessionCwd{}, - agent: map[string]string{}, sandbox: map[string]sandboxEvent{}, relay: map[string]sessionRelay{}, git: map[string]lichclient.DiffStats{}, - base: map[string]*lichclient.BaseStatus{}, prs: map[string]prLookup{}, folds: map[string]bool{}, + agent: map[string]string{}, sandbox: map[string]sandboxEvent{}, relay: map[string]sessionRelay{}, git: map[string]backend.DiffStats{}, + base: map[string]*backend.BaseStatus{}, prs: map[string]prLookup{}, folds: map[string]bool{}, profiles: newProfileBook(claudeProfileProvider, client, invalidate, quota), codexProfiles: newProfileBook(codexProfileProvider, client, invalidate, quota), ompProfiles: newProfileBook(ompProfileProvider, client, invalidate, quota), @@ -163,7 +158,7 @@ func (m *model) reload(ctx context.Context) error { m.launched = true m.activeProject, m.home = launch, launch == "" || launch == m.homeID } - if p := m.shownProject(); p != nil && !slices.ContainsFunc(p.Sessions, func(s lichclient.Session) bool { return s.ID == m.active }) { + if p := m.shownProject(); p != nil && !slices.ContainsFunc(p.Sessions, func(s backend.Session) bool { return s.ID == m.active }) { m.active = p.ActiveSessionID } pruned := m.layouts.prune(m.liveLocked) @@ -184,13 +179,13 @@ func (m *model) reload(ctx context.Context) error { // launchProject is the project the window opens on: the one the last screen // was on while it is still open, else "" for Home, where anything else lands // too, Home and a closed project included. -func (m *model) launchProject(ctx context.Context, ps []lichclient.Project) (string, error) { +func (m *model) launchProject(ctx context.Context, ps []backend.Project) (string, error) { saved, err := m.client.GetSetting(ctx, lastScreenKey, "") if err != nil { return "", err } if match := projectScreen.FindStringSubmatch(saved); match != nil { - if slices.ContainsFunc(ps, func(p lichclient.Project) bool { return p.ID == match[1] }) { + if slices.ContainsFunc(ps, func(p backend.Project) bool { return p.ID == match[1] }) { return match[1], nil } } @@ -199,11 +194,11 @@ func (m *model) launchProject(ctx context.Context, ps []lichclient.Project) (str // shownProject is the active project, else the first when the active one is // not open; nil on Home. Callers hold m.mu. -func (m *model) shownProject() *lichclient.Project { +func (m *model) shownProject() *backend.Project { if m.home { return m.projectByID(m.homeID) } - if i := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == m.activeProject }); i >= 0 { + if i := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == m.activeProject }); i >= 0 { return &m.projects[i] } if len(m.projects) == 0 { @@ -217,7 +212,7 @@ func (m *model) shownProject() *lichclient.Project { func (m *model) setActiveProject(id string) bool { m.mu.Lock() defer m.mu.Unlock() - i := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == id }) + i := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == id }) if i < 0 { return false } @@ -247,10 +242,16 @@ func (m *model) hydrateTurnStates(ctx context.Context) error { } m.mu.Unlock() for _, ts := range states { - if len(ts.Usage) == 0 { + if ts.Usage == nil { continue } - if err := m.usage.seed(ts.Usage); err != nil { + // seed reads the session-usage event's own bytes, the one decoding the + // /events stream goes through too. + raw, err := json.Marshal(ts.Usage) + if err == nil { + err = m.usage.seed(raw) + } + if err != nil { log.Printf("terminal.TurnStates: usage of %s: %v", ts.ID, err) } } @@ -261,14 +262,14 @@ func (m *model) hydrateTurnStates(ctx context.Context) error { // follow applies /events until ctx ends. The backend replays nothing beyond // what hydrateTurnStates read. When the stream ends first, the backend dropped this window: /events // serves one client and a newer one replaces it, or lich is gone. -func (m *model) follow(ctx context.Context, events <-chan lichclient.Event) { +func (m *model) follow(ctx context.Context, events <-chan backend.Event) { for ev := range events { - if id, exited := strings.CutPrefix(ev.Name, exitEventPrefix); exited { + if id, exited := api.TerminalExitEvents.ID(ev.Name); exited { m.mu.Lock() m.status.exited(id) m.mu.Unlock() if m.exited != nil { - m.exited(id, readSessionExit(ev.Data)) + m.exited(id, readSessionExit(ev)) } m.invalidate() continue @@ -280,113 +281,95 @@ func (m *model) follow(ctx context.Context, events <-chan lichclient.Event) { } m.quota.apply(ev) switch ev.Name { - case "session-usage": + case api.SessionUsageEvent.Name: // Taken by the usage store above; only the redraw is left. - case statusEventName: + case api.SessionStatusEvent.Name: if err := m.applyStatus(ctx, ev); err != nil { log.Printf("%s: %v", ev.Name, err) } - case "session-cwd": - var c sessionCwd - if json.Unmarshal(ev.Data, &c) == nil { + case api.SessionCwdEvent.Name: + if c, err := backend.Decode(ev, api.SessionCwdEvent); err == nil { m.mu.Lock() m.cwd[c.ID] = c m.mu.Unlock() } - case "session-title": - var s struct { - ID string `json:"id"` - Label string `json:"label"` - } - if json.Unmarshal(ev.Data, &s) == nil { + case api.SessionTitleEvent.Name: + if s, err := backend.Decode(ev, api.SessionTitleEvent); err == nil { m.rename(s.ID, s.Label) } - case lichclient.SessionConversationEvent: - var c lichclient.ConversationReported - if json.Unmarshal(ev.Data, &c) == nil { + case api.SessionConversationEvent.Name: + if c, err := backend.Decode(ev, api.SessionConversationEvent); err == nil { m.setConversation(c.ID, c.ProviderSessionID) } - case lichclient.SessionProfileEvent: - var moved lichclient.ProfileMoved - if json.Unmarshal(ev.Data, &moved) == nil { + case api.SessionProfileEvent.Name: + if moved, err := backend.Decode(ev, api.SessionProfileEvent); err == nil { m.setProfile(moved.ID, moved.Profile) } - case lichclient.ProfileChangedEvent: - var c lichclient.ProfileChanged - if json.Unmarshal(ev.Data, &c) == nil { + case api.ProfileChangedEvent.Name: + if c, err := backend.Decode(ev, api.ProfileChangedEvent); err == nil { m.profileChanged(c) } - case lichclient.ClaudeProfileLoginEvent, lichclient.CodexProfileLoginEvent: - var step lichclient.ProfileLogin + case api.ClaudeProfileLoginEvent.Name, api.CodexProfileLoginEvent.Name: + var step backend.ProfileLogin if json.Unmarshal(ev.Data, &step) == nil { m.loginStep(ctx, loginEventKind(ev.Name), step) } - case lichclient.PluginProfilesEvent: - var pass lichclient.PluginProfiles - if json.Unmarshal(ev.Data, &pass) == nil && m.pluginProfiles != nil { + case api.PluginProfilesEvent.Name: + if pass, err := backend.Decode(ev, api.PluginProfilesEvent); err == nil && m.pluginProfiles != nil { m.pluginProfiles(pass) } - case lichclient.ThemeSelectionEvent: - var sel lichclient.ThemeSelection - if json.Unmarshal(ev.Data, &sel) == nil && m.themeSelection != nil { + case api.ThemeSelectionEvent.Name: + if sel, err := backend.Decode(ev, api.ThemeSelectionEvent); err == nil && m.themeSelection != nil { m.themeSelection(sel) } - case lichclient.PRChangedEvent: + case api.PullRequestChangedEvent.Name: if m.prChanged != nil { m.prChanged() } - case lichclient.AppUpdateProgressEvent: - var p lichclient.AppUpdateProgress - if json.Unmarshal(ev.Data, &p) == nil && m.appUpdateProgress != nil { + case api.AppUpdateProgressEvent.Name: + if p, err := backend.Decode(ev, api.AppUpdateProgressEvent); err == nil && m.appUpdateProgress != nil { m.appUpdateProgress(p) } - case duplicateEventName: - var d sessionDuplicate - if json.Unmarshal(ev.Data, &d) == nil && m.duplicate != nil { + case api.SessionDuplicateEvent.Name: + if d, err := backend.Decode(ev, api.SessionDuplicateEvent); err == nil && m.duplicate != nil { m.duplicate(d) } - case lichclient.PluginIncompatibleEvent: + case api.PluginIncompatibleEvent.Name: if m.pluginIncompatible != nil { m.pluginIncompatible() } - case touchedEventName: - m.sessionTouched(ctx, ev.Data) - case focusEventName: - m.sessionFocus(ev.Data) - case raiseEventName: + case api.SessionTouchedEvent.Name: + m.sessionTouched(ctx, ev) + case api.SessionFocusEvent.Name: + m.sessionFocus(ev) + case api.WindowRaiseEvent.Name: if m.raise != nil { m.raise() } - case closeEventName: + case api.WindowCloseEvent.Name: log.Print("lich asked this window to close") if m.leave != nil { m.leave() } - case "project-closed": - var gone struct { - ID string `json:"id"` - } - if json.Unmarshal(ev.Data, &gone) == nil { + case api.ProjectClosedEvent.Name: + if gone, err := backend.Decode(ev, api.ProjectClosedEvent); err == nil { m.closed.stale() if out, ok := m.closeProject(gone.ID); ok && m.projectClosed != nil { m.projectClosed(out) } } - case "projects-reordered": - var order struct { - IDs []string `json:"ids"` - } - if json.Unmarshal(ev.Data, &order) == nil { + case api.ProjectsReorderedEvent.Name: + if order, err := backend.Decode(ev, api.ProjectsReorderedEvent); err == nil { m.applyOrder(order.IDs) } - case "project-added", "project-opened": + case api.ProjectAddedEvent.Name, api.ProjectOpenedEvent.Name: m.closed.stale() if err := m.reload(ctx); err != nil { log.Printf("reload after %s: %v", ev.Name, err) } else if err := m.loadFolds(ctx); err != nil { log.Printf("load folds after %s: %v", ev.Name, err) } - case "session-opened", "session-closed", "sessions-filed", "sessions-colored": + case api.SessionOpenedEvent.Name, api.SessionClosedEvent.Name, api.SessionsFiledEvent.Name, api.SessionsColoredEvent.Name: if err := m.reload(ctx); err != nil { log.Printf("reload after %s: %v", ev.Name, err) } else if err := m.loadFolds(ctx); err != nil { @@ -477,7 +460,7 @@ func (m *model) shownPaths() []string { add(m.shownPath(*p, s)) } // The footer reads the project's own checkout while no card is active. - if !slices.ContainsFunc(p.Sessions, func(s lichclient.Session) bool { return s.ID == m.active }) { + if !slices.ContainsFunc(p.Sessions, func(s backend.Session) bool { return s.ID == m.active }) { add(p.Path) } return paths @@ -485,14 +468,14 @@ func (m *model) shownPaths() []string { // shownPath is where the card says a session is: its live cwd, else its // checkout. Callers hold m.mu. -func (m *model) shownPath(p lichclient.Project, s lichclient.Session) string { +func (m *model) shownPath(p backend.Project, s backend.Session) string { if c := m.cwd[s.ID]; c.Cwd != "" { return c.Cwd } return sessionPath(p, s) } -func sessionPath(p lichclient.Project, s lichclient.Session) string { +func sessionPath(p backend.Project, s backend.Session) string { if s.Path != "" { return s.Path } @@ -503,7 +486,7 @@ func sessionPath(p lichclient.Project, s lichclient.Session) string { // checkout it was spawned in, which groups it; shown is where its shell is now, // and git describes shown. type sessionView struct { - lichclient.Session + backend.Session projectID string path string shown string @@ -517,9 +500,9 @@ type sessionView struct { relay sessionRelay // Direction "" for none tool sessionTool // Name "" outside a tool call reason string // what a waiting session is blocked on, "" for unsaid - git lichclient.DiffStats - base *lichclient.BaseStatus // nil without a base to stand against - pr *lichclient.PullRequest // open PR of the shown path's branch, nil for none + git backend.DiffStats + base *backend.BaseStatus // nil without a base to stand against + pr *backend.PullRequest // open PR of the shown path's branch, nil for none // profileName is the profile the session runs in, "" for Personal; // continueIn are the other logins its conversation could move to. profileName string @@ -532,7 +515,7 @@ type sessionView struct { } // viewOf is what s's card shows at now. Callers hold m.mu. -func (m *model) viewOf(p lichclient.Project, s lichclient.Session, now time.Time) sessionView { +func (m *model) viewOf(p backend.Project, s backend.Session, now time.Time) sessionView { shown := m.shownPath(p, s) age, aged := m.status.age(s.ID, now) tool, _ := m.status.tool(s.ID) @@ -550,7 +533,7 @@ func (m *model) viewOf(p lichclient.Project, s lichclient.Session, now time.Time // gitStatus is path's last branch and changes, false while git has not // answered for it or it is not a checkout. -func (m *model) gitStatus(path string) (lichclient.DiffStats, bool) { +func (m *model) gitStatus(path string) (backend.DiffStats, bool) { m.mu.Lock() defer m.mu.Unlock() g, ok := m.git[path] @@ -586,7 +569,7 @@ func (m *model) activeSession() (sessionView, bool) { if p == nil { return sessionView{}, false } - i := slices.IndexFunc(p.Sessions, func(s lichclient.Session) bool { return s.ID == m.active }) + i := slices.IndexFunc(p.Sessions, func(s backend.Session) bool { return s.ID == m.active }) if i < 0 { return sessionView{}, false } @@ -621,12 +604,12 @@ func (m *model) setActive(ctx context.Context, id string) { } // project returns the active project. -func (m *model) project() (lichclient.Project, bool) { +func (m *model) project() (backend.Project, bool) { m.mu.Lock() defer m.mu.Unlock() p := m.shownProject() if p == nil { - return lichclient.Project{}, false + return backend.Project{}, false } return *p, true } @@ -668,9 +651,9 @@ func (m *model) closeSession(id string) (projectID, active string, ok bool) { // sessionAt finds the project holding a session and its slot there. Callers // hold m.mu. -func (m *model) sessionAt(id string) (*lichclient.Project, int) { +func (m *model) sessionAt(id string) (*backend.Project, int) { for pi := range m.projects { - if i := slices.IndexFunc(m.projects[pi].Sessions, func(s lichclient.Session) bool { return s.ID == id }); i >= 0 { + if i := slices.IndexFunc(m.projects[pi].Sessions, func(s backend.Session) bool { return s.ID == id }); i >= 0 { return &m.projects[pi], i } } @@ -708,7 +691,7 @@ func baseName(path string) string { // unreadSessions is every session the workspace database says came back with // a finished turn nobody read. -func unreadSessions(ps []lichclient.Project) []string { +func unreadSessions(ps []backend.Project) []string { var ids []string for _, p := range ps { for _, s := range p.Sessions { @@ -732,7 +715,7 @@ func checkoutPath(dir, projectPath string) string { // normalizeCheckouts stores a session in the project's own directory with no // path, as checkoutPath does at creation. Native builds before that fix // wrote the project's path instead, which split the root block in two. -func normalizeCheckouts(ps []lichclient.Project) { +func normalizeCheckouts(ps []backend.Project) { for pi := range ps { for si := range ps[pi].Sessions { s := &ps[pi].Sessions[si] diff --git a/native/cmd/lich-native/model_events.go b/native/cmd/lich-native/model_events.go index af90c7c30..3e69a582c 100644 --- a/native/cmd/lich-native/model_events.go +++ b/native/cmd/lich-native/model_events.go @@ -2,33 +2,18 @@ package main import ( "context" - "encoding/json" "slices" - "github.com/lichdotdev/lich/native/lichclient" -) - -const ( - // touchedEventName is terminal.touchedEventName: a session likely changed - // files on disk. - touchedEventName = "session-touched" - // focusEventName is relay.FocusEventName: `lich focus` asked for a card. - focusEventName = "session-focus" - // raiseEventName is main.windowRaiseEvent: the tray or a second launch - // asked for lich's window. - raiseEventName = "window-raise" - // closeEventName is main.windowCloseEvent: lich is quitting. - closeEventName = "window-close" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // sessionTouched reads a session's git state now rather than at the next // tick, and tells the Review tab's diff. A session of a project that is not on // screen has nothing drawn from git, so it waits for its project to be shown. -func (m *model) sessionTouched(ctx context.Context, data json.RawMessage) { - var e struct { - ID string `json:"id"` - } - if json.Unmarshal(data, &e) != nil { +func (m *model) sessionTouched(ctx context.Context, ev backend.Event) { + e, err := backend.Decode(ev, api.SessionTouchedEvent) + if err != nil { return } m.mu.Lock() @@ -65,11 +50,9 @@ func (m *model) checkoutWritten(ctx context.Context, checkout string) { // sessionFocus brings the card `lich focus` named up. One whose project is not // open here has nowhere to be shown, so it is left alone. -func (m *model) sessionFocus(data json.RawMessage) { - var e struct { - ID string `json:"id"` - } - if json.Unmarshal(data, &e) != nil || m.focusSession == nil { +func (m *model) sessionFocus(ev backend.Event) { + e, err := backend.Decode(ev, api.SessionFocusEvent) + if err != nil || m.focusSession == nil { return } m.mu.Lock() @@ -86,9 +69,9 @@ func (m *model) sessionFocus(data json.RawMessage) { // profileChanged takes a profile's update into its provider's book, whichever // window or call made it. -func (m *model) profileChanged(c lichclient.ProfileChanged) { +func (m *model) profileChanged(c backend.ProfileChanged) { if book := m.profileBook(c.Provider); book != nil { - book.changed(c.Profile) + book.changed(backend.Profile{Profile: c.Profile}) } } @@ -96,7 +79,7 @@ func (m *model) profileChanged(c lichclient.ProfileChanged) { // profiles are read again before the screen hears the login ended, so what it // draws next is the account that just signed in, whichever window or terminal // ran the login. -func (m *model) loginStep(ctx context.Context, kind string, step lichclient.ProfileLogin) { +func (m *model) loginStep(ctx context.Context, kind string, step backend.ProfileLogin) { if !step.Done { m.forwardLogin(kind, step) return @@ -111,13 +94,13 @@ func (m *model) loginStep(ctx context.Context, kind string, step lichclient.Prof // loginEventKind is the provider a login event name belongs to. func loginEventKind(event string) string { - if event == lichclient.CodexProfileLoginEvent { + if event == api.CodexProfileLoginEvent.Name { return codexKind } return claudeKind } -func (m *model) forwardLogin(kind string, step lichclient.ProfileLogin) { +func (m *model) forwardLogin(kind string, step backend.ProfileLogin) { if m.loginProgress != nil { m.loginProgress(kind, step) } diff --git a/native/cmd/lich-native/model_layouts_test.go b/native/cmd/lich-native/model_layouts_test.go index 328a7d790..6965c34c0 100644 --- a/native/cmd/lich-native/model_layouts_test.go +++ b/native/cmd/lich-native/model_layouts_test.go @@ -12,7 +12,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // layoutsBackend answers every call with null and keeps the last value @@ -43,14 +44,12 @@ func newLayoutsModel(t *testing.T) (*model, *layoutsBackend) { t.Helper() b := new(layoutsBackend) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var args []json.RawMessage + var args []api.SetSettingOptions body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &args) - if r.URL.Path == "/rpc/store.SetSetting" && len(args) == 3 && string(args[0]) == strconv.Quote(layoutsKey) { - var v string - _ = json.Unmarshal(args[2], &v) + if r.URL.Path == "/rpc/store.SetSetting" && len(args) == 1 && args[0].Key == layoutsKey { b.mu.Lock() - b.stored, b.saves = v, b.saves+1 + b.stored, b.saves = args[0].Value, b.saves+1 b.mu.Unlock() } _ = json.NewEncoder(w).Encode(nil) @@ -58,10 +57,10 @@ func newLayoutsModel(t *testing.T) (*model, *layoutsBackend) { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - m := newModel(lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}), func() {}) - m.projects = []lichclient.Project{ - {ID: "lich", Name: "lich", Path: "/dev/lich", ActiveSessionID: "orch", Sessions: []lichclient.Session{{ID: "orch", Label: "orchestrator"}, {ID: "notes", Label: "notes"}}}, - {ID: "plugin", Name: "lich-plugin", Path: "/dev/plugin", ActiveSessionID: "wa", Sessions: []lichclient.Session{{ID: "wa", Label: "worker-a"}, {ID: "wb", Label: "worker-b"}}}, + m := newModel(backend.New(backend.Runtime{Port: port, Token: "tok"}), func() {}) + m.projects = []backend.Project{ + {ID: "lich", Name: "lich", Path: "/dev/lich", ActiveSessionID: "orch", Sessions: []backend.Session{{ID: "orch", Label: "orchestrator"}, {ID: "notes", Label: "notes"}}}, + {ID: "plugin", Name: "lich-plugin", Path: "/dev/plugin", ActiveSessionID: "wa", Sessions: []backend.Session{{ID: "wa", Label: "worker-a"}, {ID: "wb", Label: "worker-b"}}}, } m.activeProject, m.active = "lich", "orch" return m, b diff --git a/native/cmd/lich-native/model_test.go b/native/cmd/lich-native/model_test.go index 4b1b4e430..2b5fb36b9 100644 --- a/native/cmd/lich-native/model_test.go +++ b/native/cmd/lich-native/model_test.go @@ -11,7 +11,8 @@ import ( "strconv" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) const ( @@ -22,10 +23,10 @@ const ( // fakeDiff serves project.Diff, answering branch "b:" with one file per // path it is asked about, and failing for notGit; a path has no base status // and no pull request. -func fakeDiff(t *testing.T, notGit string) *lichclient.Client { +func fakeDiff(t *testing.T, notGit string) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var args []string + var args []api.PathOptions body, _ := io.ReadAll(r.Body) if r.URL.Path == "/rpc/project.BaseStatus" || r.URL.Path == "/rpc/project.PullRequest" { _ = json.NewEncoder(w).Encode(nil) @@ -34,22 +35,22 @@ func fakeDiff(t *testing.T, notGit string) *lichclient.Client { if err := json.Unmarshal(body, &args); err != nil || r.URL.Path != "/rpc/project.Diff" { t.Errorf("unexpected call %s %s", r.URL.Path, body) } - if args[0] == notGit { + if args[0].Path == notGit { w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(map[string]string{"error": "not a git repository"}) return } - _ = json.NewEncoder(w).Encode(lichclient.DiffStats{Files: 1, Added: 3, Deleted: 2, Branch: "b:" + args[0]}) + _ = json.NewEncoder(w).Encode(backend.DiffStats{Files: 1, Added: 3, Deleted: 2, Branch: "b:" + args[0].Path}) })) t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } -func newTestModel(client *lichclient.Client) *model { +func newTestModel(client *backend.Client) *model { m := newModel(client, func() {}) - m.projects = []lichclient.Project{{ID: "p", Path: checkout, Sessions: []lichclient.Session{{ID: "s1", Label: "Shell 1"}}}} + m.projects = []backend.Project{{ID: "p", Path: checkout, Sessions: []backend.Session{{ID: "s1", Label: "Shell 1"}}}} return m } @@ -64,8 +65,8 @@ func cardOf(t *testing.T, m *model) sessionView { func send(m *model, name string, data any) { raw, _ := json.Marshal(data) - events := make(chan lichclient.Event, 1) - events <- lichclient.Event{Name: name, Data: raw} + events := make(chan backend.Event, 1) + events <- backend.Event{Name: name, Data: raw} close(events) m.follow(context.Background(), events) } @@ -128,7 +129,7 @@ func TestCloseSessionActivatesNeighbor(t *testing.T) { return out } m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Sessions: []lichclient.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}}}} + m.projects = []backend.Project{{ID: "p", Sessions: []backend.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}}}} m.active = "b" if p, active, ok := m.closeSession("b"); !ok || p != "p" || active != "c" { @@ -151,7 +152,7 @@ func TestCloseSessionActivatesNeighbor(t *testing.T) { func TestFollowMarksTheWindowLostWhenTheStreamEnds(t *testing.T) { m := newModel(nil, func() {}) - events := make(chan lichclient.Event) + events := make(chan backend.Event) close(events) m.follow(context.Background(), events) if !m.connectionLost() { @@ -163,7 +164,7 @@ func TestFollowStaysQuietWhenTheWindowCloses(t *testing.T) { m := newModel(nil, func() {}) ctx, cancel := context.WithCancel(context.Background()) cancel() - events := make(chan lichclient.Event) + events := make(chan backend.Event) close(events) m.follow(ctx, events) if m.connectionLost() { @@ -174,7 +175,7 @@ func TestFollowStaysQuietWhenTheWindowCloses(t *testing.T) { func TestGroupNamesAWorktreeWithASlashByItsWholeName(t *testing.T) { const root = "/home/me/.local/share/lich/worktrees/p" m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Path: checkout, Sessions: []lichclient.Session{ + m.projects = []backend.Project{{ID: "p", Path: checkout, Sessions: []backend.Session{ {ID: "a"}, {ID: "b", Path: root + "/feat/x"}, {ID: "c", Path: root + "/fix/x"}, }}} var names []string @@ -208,11 +209,11 @@ func TestCheckoutLabel(t *testing.T) { } // fakeHome is the home directory's project fakeState answers project.Home with. -var fakeHome = lichclient.Project{ID: "home", Name: "me", Path: "/home/me"} +var fakeHome = backend.Project{ID: "home", Name: "me", Path: "/home/me"} // fakeState serves store.LoadState with *projects and store.GetSetting with // lastScreen, and opens fakeHome into *projects when the window asks for it. -func fakeState(t *testing.T, projects *[]lichclient.Project, lastScreen string) *lichclient.Client { +func fakeState(t *testing.T, projects *[]backend.Project, lastScreen string) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -226,10 +227,10 @@ func fakeState(t *testing.T, projects *[]lichclient.Project, lastScreen string) *projects = append(*projects, fakeHome) _ = json.NewEncoder(w).Encode(nil) case "/rpc/store.AddSession": - var args []any + var args []api.AddSessionOptions _ = json.NewDecoder(r.Body).Decode(&args) last := &(*projects)[len(*projects)-1] - last.Sessions = append(last.Sessions, lichclient.Session{ID: args[1].(string), Kind: "shell"}) + last.Sessions = append(last.Sessions, backend.Session{ID: args[0].SessionID, Kind: "shell"}) _ = json.NewEncoder(w).Encode(nil) default: t.Errorf("unexpected call %s", r.URL.Path) @@ -238,13 +239,13 @@ func fakeState(t *testing.T, projects *[]lichclient.Project, lastScreen string) t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } -func twoProjects() []lichclient.Project { - return []lichclient.Project{ - {ID: "a", Path: checkout, ActiveSessionID: "a2", Sessions: []lichclient.Session{{ID: "a1"}, {ID: "a2"}}}, - {ID: "b", Path: elsewhere, ActiveSessionID: "b1", Sessions: []lichclient.Session{{ID: "b1"}}}, +func twoProjects() []backend.Project { + return []backend.Project{ + {ID: "a", Path: checkout, ActiveSessionID: "a2", Sessions: []backend.Session{{ID: "a1"}, {ID: "a2"}}}, + {ID: "b", Path: elsewhere, ActiveSessionID: "b1", Sessions: []backend.Session{{ID: "b1"}}}, } } @@ -272,7 +273,7 @@ func TestLaunchOpensTheProjectTheLastScreenWasOn(t *testing.T) { } func TestReloadKeepsTheActiveProjectAndAdoptsAnOpenedOne(t *testing.T) { - var ps []lichclient.Project + var ps []backend.Project m := newModel(fakeState(t, &ps, ""), func() {}) if err := m.reload(context.Background()); err != nil { t.Fatal(err) @@ -337,7 +338,7 @@ func TestCloseSessionFindsItInAnyProject(t *testing.T) { // path; such a session belongs to the root block, beside the ones stored // with no path. func TestSessionStoredAtTheProjectDirSharesTheRootBlock(t *testing.T) { - ps := []lichclient.Project{{ID: "p", Path: checkout, Sessions: []lichclient.Session{ + ps := []backend.Project{{ID: "p", Path: checkout, Sessions: []backend.Session{ {ID: "old", Path: checkout}, {ID: "new"}, }}} m := newModel(fakeState(t, &ps, "/projects/p"), func() {}) @@ -354,16 +355,16 @@ func TestASessionMovingProfileMakesItsQuotaReadAgain(t *testing.T) { r := newQuotaRig() m := newModel(nil, func() {}) m.quota = r.q - personal := quotaPlan(lichclient.QuotaWindow{Label: "Session", Percent: 10}) - r.set([]lichclient.QuotaPlan{personal}, nil) + personal := quotaPlan(backend.QuotaWindow{Label: "Session", Percent: 10}) + r.set([]backend.QuotaPlan{personal}, nil) defer r.q.watch("s1")() waitChanged(t, r.changed) work := personal work.Account = "me@work.example" - r.set([]lichclient.QuotaPlan{work}, nil) - events := make(chan lichclient.Event, 1) - events <- lichclient.Event{Name: lichclient.SessionProfileEvent, Data: []byte(`{"id":"s1","profile":"work"}`)} + r.set([]backend.QuotaPlan{work}, nil) + events := make(chan backend.Event, 1) + events <- backend.Event{Name: api.SessionProfileEvent.Name, Data: []byte(`{"id":"s1","profile":"work"}`)} close(events) m.follow(context.Background(), events) waitChanged(t, r.changed) diff --git a/native/cmd/lich-native/notify.go b/native/cmd/lich-native/notify.go index f73ae2572..9482d1891 100644 --- a/native/cmd/lich-native/notify.go +++ b/native/cmd/lich-native/notify.go @@ -6,8 +6,8 @@ import ( "strconv" "sync" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // These two live in the backend. Renaming one would orphan what is already @@ -202,7 +202,7 @@ func parseNotifyPrefs(attention, finished string) notifyPrefs { return p } -func loadNotifyPrefs(ctx context.Context, c *lichclient.Client) (notifyPrefs, error) { +func loadNotifyPrefs(ctx context.Context, c *backend.Client) (notifyPrefs, error) { attention, err := c.GetSetting(ctx, desktopNotifyKey, "") if err != nil { return notifyPrefs{}, err @@ -212,7 +212,7 @@ func loadNotifyPrefs(ctx context.Context, c *lichclient.Client) (notifyPrefs, er } // newNotifier is the window's desktop channel; sink raises what it decides. -func newNotifier(ctx context.Context, c *lichclient.Client, sink noteSink, invalidate func()) *notifier { +func newNotifier(ctx context.Context, c *backend.Client, sink noteSink, invalidate func()) *notifier { return ¬ifier{ invalidate: invalidate, sink: sink, diff --git a/native/cmd/lich-native/notify_test.go b/native/cmd/lich-native/notify_test.go index d2aec5f3f..9ebbaf2fa 100644 --- a/native/cmd/lich-native/notify_test.go +++ b/native/cmd/lich-native/notify_test.go @@ -5,7 +5,7 @@ import ( "slices" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) type sentNotice struct{ summary, detail string } @@ -39,7 +39,7 @@ func newNotifyRig(prefs notifyPrefs) *notifyRig { sink: fakeNotes{sent: &r.sent, withdrawn: &r.withdrawn}, store: func(key, value string) { r.stored[key] = value }} r.m = newModel(nil, func() {}) - r.m.projects = []lichclient.Project{{ID: "p", Name: "lich", Sessions: []lichclient.Session{ + r.m.projects = []backend.Project{{ID: "p", Name: "lich", Sessions: []backend.Session{ {ID: "a", Label: "fix the build"}, {ID: "b"}, }}} r.m.notify = r.n.report diff --git a/native/cmd/lich-native/palette.go b/native/cmd/lich-native/palette.go index 3cc2c1655..264bf267e 100644 --- a/native/cmd/lich-native/palette.go +++ b/native/cmd/lich-native/palette.go @@ -15,8 +15,8 @@ import ( "gioui.org/op" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -99,8 +99,8 @@ type commandPalette struct { closedOff bool history remote[historyAnswer] - external remote[[]lichclient.ExternalSession] - said remote[[]lichclient.TranscriptMatch] + external remote[[]backend.ExternalSession] + said remote[[]backend.TranscriptMatch] } func newCommandPalette(invalidate func()) *commandPalette { @@ -212,7 +212,7 @@ func (p *commandPalette) keys(gtx C) (enter bool) { // hand: the store answers with one page, so a session parked or a project // closed further back than that page is only reachable if the match happens // there. -func (p *commandPalette) ask(gtx C, c *lichclient.Client, typed string, searching, onHistory bool, sessions []paletteSession) { +func (p *commandPalette) ask(gtx C, c *backend.Client, typed string, searching, onHistory bool, sessions []paletteSession) { reset := strconv.Itoa(p.opened) if !searching { p.pollAt = time.Time{} @@ -244,7 +244,7 @@ func (p *commandPalette) ask(gtx C, c *lichclient.Client, typed string, searchin for i, s := range sessions { ids[i] = s.sessionID } - p.said.want(term+"\x00"+strings.Join(ids, ","), reset, func(ctx context.Context) ([]lichclient.TranscriptMatch, error) { + p.said.want(term+"\x00"+strings.Join(ids, ","), reset, func(ctx context.Context) ([]backend.TranscriptMatch, error) { matches, err := c.SearchTranscripts(ctx, ids, term) if err != nil { // A search that only ever adds: a failed one reads as no rows and @@ -283,7 +283,7 @@ func (p *commandPalette) wake(gtx C) { } } -func fetchHistory(ctx context.Context, c *lichclient.Client, term string) (historyAnswer, error) { +func fetchHistory(ctx context.Context, c *backend.Client, term string) (historyAnswer, error) { h, err := c.ClosedSessions(ctx, term) if err != nil { return historyAnswer{}, err @@ -376,7 +376,7 @@ func (p *commandPalette) view(w paletteWorld, bound hotkeyBindings, isMac bool, } // answers are the searches' latest answers, empty while an opening has none. -func (p *commandPalette) answers() (closedAnswer, historyAnswer, []lichclient.ExternalSession, []lichclient.TranscriptMatch) { +func (p *commandPalette) answers() (closedAnswer, historyAnswer, []backend.ExternalSession, []backend.TranscriptMatch) { var closed closedAnswer if p.closed != nil && !p.closedOff { closed = p.closed.read(p.closedTerm) diff --git a/native/cmd/lich-native/palette_logic.go b/native/cmd/lich-native/palette_logic.go index a78ec3571..88f94154e 100644 --- a/native/cmd/lich-native/palette_logic.go +++ b/native/cmd/lich-native/palette_logic.go @@ -5,8 +5,8 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // The command palette's data join: open projects and their sessions, the closed @@ -43,7 +43,7 @@ type paletteSession struct { } // paletteProject is an open or a closed project: the same three fields. -type paletteProject = lichclient.RecentProject +type paletteProject = backend.RecentProject // paletteHistory is one parked session as the History tab lists it: the // store's row plus the branch its checkout is on now, read from git. That is @@ -51,7 +51,7 @@ type paletteProject = lichclient.RecentProject // branch is "" for a checkout that is gone, which is also the row that cannot // be resumed. type paletteHistory struct { - lichclient.ClosedSession + backend.ClosedSession branch string // gone is the checkout no longer on disk: the row forgets rather than // resumes. @@ -118,7 +118,7 @@ type paletteRow struct { project paletteProject message paletteMessage history paletteHistory - external lichclient.ExternalSession + external backend.ExternalSession action hotkeyID keys string } @@ -143,11 +143,11 @@ type paletteResults struct { closed []paletteProject closedTotal int history []paletteHistory - external []lichclient.ExternalSession + external []backend.ExternalSession } // paletteSessionsOf flattens every session of the open projects. -func paletteSessionsOf(projects []lichclient.Project) []paletteSession { +func paletteSessionsOf(projects []backend.Project) []paletteSession { var rows []paletteSession for _, p := range projects { for _, s := range p.Sessions { @@ -162,7 +162,7 @@ func paletteSessionsOf(projects []lichclient.Project) []paletteSession { // historyRows joins the parked rows to what git says about their checkouts. A // path with no branch and a path in missing are different failures, not a // repository versus not there at all, but the row only acts on the second. -func historyRows(closed []lichclient.ClosedSession, branches map[string]string, missing map[string]bool) []paletteHistory { +func historyRows(closed []backend.ClosedSession, branches map[string]string, missing map[string]bool) []paletteHistory { rows := make([]paletteHistory, len(closed)) for i, s := range closed { rows[i] = paletteHistory{ClosedSession: s, branch: branches[s.Path], gone: missing[s.Path]} @@ -176,7 +176,7 @@ func historyRows(closed []lichclient.ClosedSession, branches map[string]string, // found by folding an accent. Both branches are in the history haystack // because they can disagree: the store matched the one recorded at the close, // and the row shows the one git reads now. -func filterPalette(query string, all []paletteSession, projects, closed []paletteProject, history []paletteHistory, closedTotal int, external []lichclient.ExternalSession) paletteResults { +func filterPalette(query string, all []paletteSession, projects, closed []paletteProject, history []paletteHistory, closedTotal int, external []backend.ExternalSession) paletteResults { r := paletteResults{closedTotal: closedTotal} for _, s := range all { if matchesQuery(s.label+" "+s.projectName+" "+s.path, query) { @@ -213,7 +213,7 @@ func filterPalette(query string, all []paletteSession, projects, closed []palett // paletteMessagesOf joins transcript matches back onto the sessions they were // searched for, in the order the search returned. A match whose session closed // since the query went out has nowhere to jump to and is dropped. -func paletteMessagesOf(matches []lichclient.TranscriptMatch, sessions []paletteSession) []paletteMessage { +func paletteMessagesOf(matches []backend.TranscriptMatch, sessions []paletteSession) []paletteMessage { var rows []paletteMessage for _, m := range matches { i := slices.IndexFunc(sessions, func(s paletteSession) bool { return s.sessionID == m.ID }) @@ -314,7 +314,7 @@ func historyAction(h paletteHistory) historyDoor { // externalLabel is what an external conversation is called on its row and on // the card it becomes: a provider that never titled it still needs a name. -func externalLabel(e lichclient.ExternalSession) string { +func externalLabel(e backend.ExternalSession) string { if title := strings.TrimSpace(e.Title); title != "" { return title } @@ -323,8 +323,8 @@ func externalLabel(e lichclient.ExternalSession) string { // adoptedSession is the parked row AdoptExternalSession files, as the // history's resume takes it: the same door a closed session goes back through. -func adoptedSession(e lichclient.ExternalSession, id string) lichclient.ClosedSession { - return lichclient.ClosedSession{ +func adoptedSession(e backend.ExternalSession, id string) backend.ClosedSession { + return backend.ClosedSession{ ID: id, ProjectID: e.ProjectID, ProjectName: e.ProjectName, ProjectPath: e.ProjectPath, Label: externalLabel(e), Kind: e.Kind, Path: e.Path, } @@ -359,7 +359,7 @@ func paletteGroupsOf(tab paletteTab, r paletteResults, messages []paletteMessage closed := rowsOf(r.closed, func(p paletteProject) paletteRow { return paletteRow{kind: rowClosed, project: p} }) said := rowsOf(messages, func(m paletteMessage) paletteRow { return paletteRow{kind: rowMessage, message: m} }) history := rowsOf(r.history, func(h paletteHistory) paletteRow { return paletteRow{kind: rowHistory, history: h} }) - external := rowsOf(r.external, func(e lichclient.ExternalSession) paletteRow { return paletteRow{kind: rowExternal, external: e} }) + external := rowsOf(r.external, func(e backend.ExternalSession) paletteRow { return paletteRow{kind: rowExternal, external: e} }) var groups []paletteGroup switch tab { diff --git a/native/cmd/lich-native/palette_logic_test.go b/native/cmd/lich-native/palette_logic_test.go index ba189fde7..ede7808c9 100644 --- a/native/cmd/lich-native/palette_logic_test.go +++ b/native/cmd/lich-native/palette_logic_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) var paletteOpen = []paletteProject{ @@ -20,13 +20,13 @@ var paletteClosed = []paletteProject{ {ID: "c4", Name: "policy-engine", Path: "/home/u/hycastle/policy-engine"}, } -func paletteWorkspace() []lichclient.Project { - return []lichclient.Project{ - {ID: "p1", Name: "lich", Path: "/home/u/try/skipo", Sessions: []lichclient.Session{ +func paletteWorkspace() []backend.Project { + return []backend.Project{ + {ID: "p1", Name: "lich", Path: "/home/u/try/skipo", Sessions: []backend.Session{ {ID: "s1", Label: "Fix flaky test", Kind: "claude"}, {ID: "s2", Label: "build watch", Kind: "shell", Path: "/home/u/try/skipo/frontend"}, }}, - {ID: "p2", Name: "revu", Path: "/home/u/try/revu", Sessions: []lichclient.Session{ + {ID: "p2", Name: "revu", Path: "/home/u/try/revu", Sessions: []backend.Session{ {ID: "s3", Label: "Wire revu auth middleware", Kind: "claude"}, }}, } @@ -104,7 +104,7 @@ func TestFilterPaletteNarrowsEachKind(t *testing.T) { func TestPaletteGroupsByTab(t *testing.T) { all := paletteSessionsOf(paletteWorkspace()) results := filterPalette("", all, paletteOpen, paletteClosed, nil, 0, nil) - messages := paletteMessagesOf([]lichclient.TranscriptMatch{{ID: "s1", Snippet: "flaky again", Count: 1}}, all) + messages := paletteMessagesOf([]backend.TranscriptMatch{{ID: "s1", Snippet: "flaky again", Count: 1}}, all) groups := paletteGroupsOf(tabAll, results, messages, 0, 0) equal(t, "All", groupLabels(groups), []string{"Sessions", "Projects", "Messages"}) @@ -177,7 +177,7 @@ func TestRankSessionsPutsWaitingThenTurnsFirstAndShellsLast(t *testing.T) { func TestRowKeysNameWhatARowOpens(t *testing.T) { all := paletteSessionsOf(paletteWorkspace()) results := filterPalette("", all, paletteOpen, paletteClosed, nil, 0, nil) - messages := paletteMessagesOf([]lichclient.TranscriptMatch{{ID: "s1", Snippet: "x", Count: 1}}, all) + messages := paletteMessagesOf([]backend.TranscriptMatch{{ID: "s1", Snippet: "x", Count: 1}}, all) equal(t, "keys", rowKeys(paletteGroupsOf(tabAll, results, messages, 0, 0)), []string{"s1", "s2", "s3", "p1", "p2", "s1"}) } @@ -205,17 +205,17 @@ func TestPaletteTabCountsAndWraps(t *testing.T) { func TestPaletteMessagesCarryTheirSessionAndSurviveAClose(t *testing.T) { all := paletteSessionsOf(paletteWorkspace()) - rows := paletteMessagesOf([]lichclient.TranscriptMatch{{ID: "s3", Snippet: "wire the auth middleware", Count: 2}}, all) + rows := paletteMessagesOf([]backend.TranscriptMatch{{ID: "s3", Snippet: "wire the auth middleware", Count: 2}}, all) want := paletteMessage{paletteSession: paletteSession{sessionID: "s3", projectID: "p2", projectName: "revu", label: "Wire revu auth middleware", kind: "claude", path: "/home/u/try/revu"}, snippet: "wire the auth middleware", count: 2} if len(rows) != 1 || rows[0] != want { t.Errorf("rows = %+v, want %+v", rows, want) } - ordered := paletteMessagesOf([]lichclient.TranscriptMatch{{ID: "s3"}, {ID: "s1"}}, all) + ordered := paletteMessagesOf([]backend.TranscriptMatch{{ID: "s3"}, {ID: "s1"}}, all) if ordered[0].sessionID != "s3" || ordered[1].sessionID != "s1" { t.Errorf("the order the search returned is kept, got %+v", ordered) } - dropped := paletteMessagesOf([]lichclient.TranscriptMatch{{ID: "s9"}, {ID: "s1"}}, all) + dropped := paletteMessagesOf([]backend.TranscriptMatch{{ID: "s9"}, {ID: "s1"}}, all) if len(dropped) != 1 || dropped[0].sessionID != "s1" { t.Errorf("a match whose session is gone is dropped, got %+v", dropped) } @@ -224,7 +224,7 @@ func TestPaletteMessagesCarryTheirSessionAndSurviveAClose(t *testing.T) { } } -var paletteParked = []lichclient.ClosedSession{ +var paletteParked = []backend.ClosedSession{ {ID: "h1", ProjectID: "p1", ProjectName: "lich", ProjectPath: "/home/u/try/skipo", Label: "Wire the relay inbox", Kind: "claude", Path: "/home/u/wt/lich/relay-inbox", ParkedBranch: "feat/relay-inbox", ClosedAt: 1_700_000_200}, {ID: "h2", ProjectID: "p1", ProjectName: "lich", ProjectPath: "/home/u/try/skipo", Label: "Conpty handle recycling", Kind: "claude", @@ -282,10 +282,10 @@ func TestHistorySearchKeepsWhatTheStoreMatched(t *testing.T) { said := paletteParked[1] said.MatchedConversation, said.Snippet = true, "…the ConPTY handle was recycled…" - kept := historyRows([]lichclient.ClosedSession{said}, paletteBranches, nil) + kept := historyRows([]backend.ClosedSession{said}, paletteBranches, nil) equal(t, "matched on its conversation", by("recycled zombie", kept), []string{"h2"}) said.Snippet = "" - folded := historyRows([]lichclient.ClosedSession{said}, paletteBranches, nil) + folded := historyRows([]backend.ClosedSession{said}, paletteBranches, nil) equal(t, "matched with no snippet to show", by("decision", folded), []string{"h2"}) } @@ -338,7 +338,7 @@ func TestHistoryIndexNoteAndAction(t *testing.T) { } } -var paletteOutside = []lichclient.ExternalSession{ +var paletteOutside = []backend.ExternalSession{ {Kind: "codex", ProviderSessionID: "c1", Title: "Port the theme loader", Path: "/wt/theme-port", ProjectID: "p1", ProjectName: "lich", ProjectPath: "/src/lich", UpdatedAt: 1788532932}, {Kind: "kiro", ProviderSessionID: "k1", ProjectID: "p2", ProjectName: "sop", ProjectPath: "/src/sop", UpdatedAt: 1788000000}, } @@ -373,7 +373,7 @@ func TestConversationsStartedOutsideLich(t *testing.T) { if externalLabel(blank) != "Untitled conversation" { t.Error("a blank title is untitled") } - want := lichclient.ClosedSession{ID: "s1", ProjectID: "p1", ProjectName: "lich", ProjectPath: "/src/lich", + want := backend.ClosedSession{ID: "s1", ProjectID: "p1", ProjectName: "lich", ProjectPath: "/src/lich", Label: "Port the theme loader", Kind: "codex", Path: "/wt/theme-port"} if got := adoptedSession(paletteOutside[0], "s1"); got != want { t.Errorf("adopted = %+v, want %+v", got, want) diff --git a/native/cmd/lich-native/palette_test.go b/native/cmd/lich-native/palette_test.go index f128edbb1..10a629a1a 100644 --- a/native/cmd/lich-native/palette_test.go +++ b/native/cmd/lich-native/palette_test.go @@ -12,7 +12,7 @@ import ( "gioui.org/op" "gioui.org/unit" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -158,7 +158,7 @@ func TestPaletteViewJoinsTheWorkspaceToTheAnswers(t *testing.T) { p.show(map[string]string{"s3": statusBusy}) p.closed = closedHolding(closedAnswer{rows: paletteClosed, total: 63, missing: map[string]bool{"/home/u/snk/gh-turnkey": true}}) p.history.data = historyAnswer{rows: historyRows(paletteParked, paletteBranches, nil), total: 143, indexing: 12} - p.said.data = []lichclient.TranscriptMatch{{ID: "s1", Snippet: "flaky again", Count: 2}} + p.said.data = []backend.TranscriptMatch{{ID: "s1", Snippet: "flaky again", Count: 2}} v := p.view(workspaceForView(), defaultHotkeys(), false, func(hotkeyID) bool { return true }) equal(t, "All", groupLabels(v.groups), []string{"Sessions", "Projects", "Messages"}) diff --git a/native/cmd/lich-native/palette_view.go b/native/cmd/lich-native/palette_view.go index 5efcd3795..484c08b93 100644 --- a/native/cmd/lich-native/palette_view.go +++ b/native/cmd/lich-native/palette_view.go @@ -15,8 +15,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -596,6 +596,6 @@ func paletteIconText(on *ui.Theme, icon *icons.Icon, s string, c color.NRGBA) la } func paletteRingOf(on *ui.Theme, ring paletteRing, kind, status string, unread bool, agent string) layout.Widget { - sv := &sessionView{Session: lichclient.Session{Kind: kind}, status: status, unread: unread, agent: agent} + sv := &sessionView{Session: backend.Session{Kind: kind}, status: status, unread: unread, agent: agent} return func(gtx C) D { return ring(gtx, on, sv) } } diff --git a/native/cmd/lich-native/palette_window.go b/native/cmd/lich-native/palette_window.go index 4f26c8a4d..a5da7e984 100644 --- a/native/cmd/lich-native/palette_window.go +++ b/native/cmd/lich-native/palette_window.go @@ -10,8 +10,8 @@ import ( "gioui.org/io/key" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -43,7 +43,7 @@ func (m *model) paletteWorld() paletteWorld { func (m *model) projectOpen(id string) bool { m.mu.Lock() defer m.mu.Unlock() - return slices.ContainsFunc(m.projects, func(p lichclient.Project) bool { return p.ID == id }) + return slices.ContainsFunc(m.projects, func(p backend.Project) bool { return p.ID == id }) } // turnsAmong is the status of each session that holds a turn. @@ -195,7 +195,7 @@ func (u *window) forgetParked(h paletteHistory) { go func() { if err := u.client.ForgetSession(u.ctx, h.ID); err != nil { u.logErr("forget session", err) - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("palette.commandPalette.forgetFailed", i18n.Args{"label": h.Label, "error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("palette.commandPalette.forgetFailed", i18n.Args{"label": h.Label, "error": backend.ErrorText(err)})}) } }() } @@ -205,9 +205,9 @@ func (u *window) forgetParked(h paletteHistory) { // relocate stops the resume rather than reopening the session into nowhere. The // backend's own resume runs it afterwards, in the profile that holds the // conversation. -func (u *window) resumeParked(c lichclient.ClosedSession) { +func (u *window) resumeParked(c backend.ClosedSession) { if !u.model.projectOpen(c.ProjectID) { - u.reopenProject(lichclient.RecentProject{ID: c.ProjectID, Name: c.ProjectName, Path: c.ProjectPath}) + u.reopenProject(backend.RecentProject{ID: c.ProjectID, Name: c.ProjectName, Path: c.ProjectPath}) if !u.model.projectOpen(c.ProjectID) { return } @@ -216,7 +216,7 @@ func (u *window) resumeParked(c lichclient.ClosedSession) { switch { case err != nil: u.logErr("reopen session", err) - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("palette.commandPalette.resumeFailed", i18n.Args{"label": c.Label, "error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("palette.commandPalette.resumeFailed", i18n.Args{"label": c.Label, "error": backend.ErrorText(err)})}) return case restored == nil: // Another window resumed it, or its worktree was removed since the @@ -234,12 +234,12 @@ func (u *window) resumeParked(c lichclient.ClosedSession) { // adoptExternal files a conversation started outside lich as a parked session // and resumes it through the history's own door, which reopens a closed // project first. -func (u *window) adoptExternal(e lichclient.ExternalSession) { +func (u *window) adoptExternal(e backend.ExternalSession) { id := strings.ToLower(rand.Text()) label := externalLabel(e) if err := u.client.AdoptExternalSession(u.ctx, e.ProjectID, id, e.Kind, e.Path, e.ProviderSessionID, label); err != nil { u.logErr("adopt external session", err) - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("palette.commandPalette.resumeFailed", i18n.Args{"label": label, "error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("palette.commandPalette.resumeFailed", i18n.Args{"label": label, "error": backend.ErrorText(err)})}) return } u.resumeParked(adoptedSession(e, id)) diff --git a/native/cmd/lich-native/paste_unfold.go b/native/cmd/lich-native/paste_unfold.go index 9224f3459..59608f243 100644 --- a/native/cmd/lich-native/paste_unfold.go +++ b/native/cmd/lich-native/paste_unfold.go @@ -11,8 +11,8 @@ import ( "context" "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -97,7 +97,7 @@ func (ts *terminals) unfolded(ctx context.Context, id, text, paste string) strin } on, err := ts.client.GetSetting(ctx, providerPasteKey(t.kind), "") if err != nil { - ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.view.pasteSettingFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.view.pasteSettingFailed", i18n.Args{"error": backend.ErrorText(err)})}) return paste } if on != "true" { diff --git a/native/cmd/lich-native/paste_unfold_test.go b/native/cmd/lich-native/paste_unfold_test.go index 76e29b259..9554c511b 100644 --- a/native/cmd/lich-native/paste_unfold_test.go +++ b/native/cmd/lich-native/paste_unfold_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/gioterm" ) @@ -92,11 +93,11 @@ func injectedWrite(t *testing.T, b *gateBackend) string { if !found { continue } - var args []string + var args []api.WriteOptions if err := json.Unmarshal([]byte(body), &args); err != nil { t.Fatal(err) } - return args[1] + return args[0].Data case <-time.After(5 * time.Second): t.Fatal("no terminal.Write") } @@ -126,7 +127,7 @@ func TestBatchOfCommentsIsSubmitted(t *testing.T) { } select { case call := <-b.calls: - var args []string + var args []api.SubmitOptions body, found := strings.CutPrefix(call, "relay.Submit ") if !found { t.Fatalf("called %s, want relay.Submit", call) @@ -134,8 +135,8 @@ func TestBatchOfCommentsIsSubmitted(t *testing.T) { if err := json.Unmarshal([]byte(body), &args); err != nil { t.Fatal(err) } - if len(args) != 2 || args[0] != "s" || args[1] != batch { - t.Errorf("relay.Submit %q, want [s %q]", args, batch) + if want := (api.SubmitOptions{SessionID: "s", Message: batch}); len(args) != 1 || args[0] != want { + t.Errorf("relay.Submit %+v, want [%+v]", args, want) } case <-time.After(5 * time.Second): t.Fatal("no relay.Submit") diff --git a/native/cmd/lich-native/patch_notes_gate.go b/native/cmd/lich-native/patch_notes_gate.go index 6177146e2..cdf9f49ea 100644 --- a/native/cmd/lich-native/patch_notes_gate.go +++ b/native/cmd/lich-native/patch_notes_gate.go @@ -3,8 +3,8 @@ package main import ( "log" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // patchNotesSeenKey is the version whose notes were last shown, a global @@ -23,7 +23,7 @@ const ( // decidePatchNotes says what to do with a build's notes: a build the changelog // has no section for shows nothing, and a version is shown once. -func decidePatchNotes(notes lichclient.PatchNotes, lastSeen string) patchNotesAction { +func decidePatchNotes(notes backend.PatchNotes, lastSeen string) patchNotesAction { switch { case notes.Version == "" || len(notes.Groups) == 0: return patchNotesNone diff --git a/native/cmd/lich-native/patch_notes_gate_test.go b/native/cmd/lich-native/patch_notes_gate_test.go index 6e0d3d99b..c2e0ba671 100644 --- a/native/cmd/lich-native/patch_notes_gate_test.go +++ b/native/cmd/lich-native/patch_notes_gate_test.go @@ -3,19 +3,19 @@ package main import ( "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestDecidePatchNotes(t *testing.T) { - notes := lichclient.PatchNotes{Version: "1.4.0", Groups: []lichclient.PatchNotesGroup{{Label: "Added"}}} + notes := backend.PatchNotes{Version: "1.4.0", Groups: []backend.PatchNotesGroup{{Label: "Added"}}} cases := []struct { name string - notes lichclient.PatchNotes + notes backend.PatchNotes lastSeen string want patchNotesAction }{ - {"dev build without a section", lichclient.PatchNotes{}, "1.3.0", patchNotesNone}, - {"section without groups", lichclient.PatchNotes{Version: "1.4.0"}, "1.3.0", patchNotesNone}, + {"dev build without a section", backend.PatchNotes{}, "1.3.0", patchNotesNone}, + {"section without groups", backend.PatchNotes{Version: "1.4.0"}, "1.3.0", patchNotesNone}, {"first launch ever", notes, "", patchNotesRecord}, {"already seen", notes, "1.4.0", patchNotesNone}, {"new release", notes, "1.3.0", patchNotesShow}, diff --git a/native/cmd/lich-native/plugin_gate.go b/native/cmd/lich-native/plugin_gate.go index dcf7ac081..7a6c98dfa 100644 --- a/native/cmd/lich-native/plugin_gate.go +++ b/native/cmd/lich-native/plugin_gate.go @@ -11,8 +11,9 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -38,14 +39,14 @@ const ( type pluginGateAction struct { kind pluginActionKind version string - providers []lichclient.PluginStatus + providers []backend.PluginStatus } // decidePluginAction picks what to offer: an incompatible install wins and // ignores every dismissal; a CLI missing the plugin comes before an update. -func decidePluginAction(statuses []lichclient.PluginStatus, installDismissed bool, updateDismissed string) pluginGateAction { - available := slices.DeleteFunc(slices.Clone(statuses), func(s lichclient.PluginStatus) bool { return !s.Available }) - var incompatible, missing, outdated []lichclient.PluginStatus +func decidePluginAction(statuses []backend.PluginStatus, installDismissed bool, updateDismissed string) pluginGateAction { + available := slices.DeleteFunc(slices.Clone(statuses), func(s backend.PluginStatus) bool { return !s.Available }) + var incompatible, missing, outdated []backend.PluginStatus for _, s := range available { switch { case s.Installed && !s.Compatible && s.Provider != pluginNoReportProvider: @@ -69,7 +70,7 @@ func decidePluginAction(statuses []lichclient.PluginStatus, installDismissed boo return pluginGateAction{} } -func incompatibleMessage(version string, providers []lichclient.PluginStatus) string { +func incompatibleMessage(version string, providers []backend.PluginStatus) string { installs := make([]string, len(providers)) for i, p := range providers { installs[i] = fmt.Sprintf("%s (v%s)", p.Name, p.InstalledVersion) @@ -87,7 +88,7 @@ type pluginGate struct { started bool mu sync.Mutex - offer []lichclient.PluginStatus + offer []backend.PluginStatus installing bool picked map[string]*widget.Bool @@ -120,7 +121,7 @@ func (u *window) checkPlugin() { log.Printf("plugin gate: %v", err) return } - statuses = slices.DeleteFunc(statuses, func(s lichclient.PluginStatus) bool { return !slices.Contains(enabled, s.Provider) }) + statuses = slices.DeleteFunc(statuses, func(s backend.PluginStatus) bool { return !slices.Contains(enabled, s.Provider) }) installDismissed, err := u.client.GetSetting(u.ctx, pluginInstallDismissedKey, "") if err != nil { log.Printf("plugin gate: %v", err) @@ -134,7 +135,7 @@ func (u *window) checkPlugin() { a := decidePluginAction(statuses, installDismissed == pluginDismissedFlag, updateDismissed) switch a.kind { case pluginActionIncompatible: - t := ui.Toast{ID: lichclient.PluginIncompatibleEvent, Variant: ui.ToastWarning, + t := ui.Toast{ID: api.PluginIncompatibleEvent.Name, Variant: ui.ToastWarning, Title: incompatibleMessage(a.version, a.providers), Duration: ui.ToastForever} if a.version != "" { t.Action = &ui.ToastButton{Label: i18n.T("shell.agentPluginGate.installVersion", i18n.Args{"version": a.version}), OnClick: func() { go u.updatePlugins(a.providers) }} @@ -160,10 +161,10 @@ func (u *window) pluginUpdateToast(a pluginGateAction) ui.Toast { // updatePlugins runs one update per provider in order, stopping at the first // that fails so the error names the CLI that refused. -func (u *window) updatePlugins(providers []lichclient.PluginStatus) { +func (u *window) updatePlugins(providers []backend.PluginStatus) { for _, p := range providers { if err := u.client.UpdatePlugin(u.ctx, p.Provider); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("update.progress.updateFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("update.progress.updateFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } } @@ -180,7 +181,7 @@ func (u *window) installPlugins(providers []string) { }() for _, p := range providers { if err := u.client.InstallPlugin(u.ctx, p); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.agentPluginGate.installFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.agentPluginGate.installFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } } @@ -264,7 +265,7 @@ func (u *window) layoutPluginGate(gtx C, th *ui.Theme) { } } -func (g *pluginGate) offerRow(gtx C, th *ui.Theme, p lichclient.PluginStatus, installing bool) D { +func (g *pluginGate) offerRow(gtx C, th *ui.Theme, p backend.PluginStatus, installing bool) D { click := g.rows[p.Provider] gtx.Constraints.Min.X = gtx.Constraints.Max.X return click.Layout(gtx, func(gtx C) D { diff --git a/native/cmd/lich-native/plugin_gate_test.go b/native/cmd/lich-native/plugin_gate_test.go index 36a1b3e31..70a3dfd6c 100644 --- a/native/cmd/lich-native/plugin_gate_test.go +++ b/native/cmd/lich-native/plugin_gate_test.go @@ -3,30 +3,30 @@ package main import ( "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestDecidePluginAction(t *testing.T) { - missing := lichclient.PluginStatus{Provider: "codex", Available: true} - outdated := lichclient.PluginStatus{Provider: "claude", Available: true, Installed: true, Compatible: true, UpdateAvailable: true, LatestVersion: "2.0.0"} - incompatible := lichclient.PluginStatus{Provider: "omp", Available: true, Installed: true, LatestVersion: "2.0.0"} - cursor := lichclient.PluginStatus{Provider: "cursor", Available: true, Installed: true} - noCLI := lichclient.PluginStatus{Provider: "crush"} + missing := backend.PluginStatus{Provider: "codex", Available: true} + outdated := backend.PluginStatus{Provider: "claude", Available: true, Installed: true, Compatible: true, UpdateAvailable: true, LatestVersion: "2.0.0"} + incompatible := backend.PluginStatus{Provider: "omp", Available: true, Installed: true, LatestVersion: "2.0.0"} + cursor := backend.PluginStatus{Provider: "cursor", Available: true, Installed: true} + noCLI := backend.PluginStatus{Provider: "crush"} cases := []struct { name string - statuses []lichclient.PluginStatus + statuses []backend.PluginStatus installDismiss bool updateDismissed string want pluginActionKind version string }{ - {"incompatible wins over dismissals", []lichclient.PluginStatus{missing, outdated, incompatible}, true, "2.0.0", pluginActionIncompatible, "2.0.0"}, - {"cursor never counts as incompatible", []lichclient.PluginStatus{cursor}, false, "", pluginActionNone, ""}, - {"missing comes before an update", []lichclient.PluginStatus{missing, outdated}, false, "", pluginActionInstall, ""}, - {"install dismissed falls to the update", []lichclient.PluginStatus{missing, outdated}, true, "", pluginActionUpdate, "2.0.0"}, - {"update dismissed for its version", []lichclient.PluginStatus{outdated}, false, "2.0.0", pluginActionNone, ""}, - {"update dismissed for an older version", []lichclient.PluginStatus{outdated}, false, "1.9.0", pluginActionUpdate, "2.0.0"}, - {"a CLI not on the machine is never offered", []lichclient.PluginStatus{noCLI}, false, "", pluginActionNone, ""}, + {"incompatible wins over dismissals", []backend.PluginStatus{missing, outdated, incompatible}, true, "2.0.0", pluginActionIncompatible, "2.0.0"}, + {"cursor never counts as incompatible", []backend.PluginStatus{cursor}, false, "", pluginActionNone, ""}, + {"missing comes before an update", []backend.PluginStatus{missing, outdated}, false, "", pluginActionInstall, ""}, + {"install dismissed falls to the update", []backend.PluginStatus{missing, outdated}, true, "", pluginActionUpdate, "2.0.0"}, + {"update dismissed for its version", []backend.PluginStatus{outdated}, false, "2.0.0", pluginActionNone, ""}, + {"update dismissed for an older version", []backend.PluginStatus{outdated}, false, "1.9.0", pluginActionUpdate, "2.0.0"}, + {"a CLI not on the machine is never offered", []backend.PluginStatus{noCLI}, false, "", pluginActionNone, ""}, } for _, c := range cases { got := decidePluginAction(c.statuses, c.installDismiss, c.updateDismissed) diff --git a/native/cmd/lich-native/profile_avatar.go b/native/cmd/lich-native/profile_avatar.go index 90e90aefd..403d98d61 100644 --- a/native/cmd/lich-native/profile_avatar.go +++ b/native/cmd/lich-native/profile_avatar.go @@ -14,7 +14,7 @@ import ( "gioui.org/unit" xdraw "golang.org/x/image/draw" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -36,7 +36,7 @@ type avatarImage struct { // decodeAvatar is a profile's image ready to draw; nil when it has none, or one // the window cannot decode, which then shows its initial. -func decodeAvatar(p lichclient.Profile) *avatarImage { +func decodeAvatar(p backend.Profile) *avatarImage { if len(p.Image) == 0 { return nil } diff --git a/native/cmd/lich-native/profiles.go b/native/cmd/lich-native/profiles.go index 9f516d6b2..7fa28d485 100644 --- a/native/cmd/lich-native/profiles.go +++ b/native/cmd/lich-native/profiles.go @@ -8,8 +8,8 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // claudeProfileKey is store.claudeProfileKey: the profile new Claude Code @@ -54,7 +54,7 @@ type profileProvider struct { kind string key string label string - api func(c *lichclient.Client) lichclient.ProfileAPI + api func(c *backend.Client) backend.ProfileAPI // quotaPrefix starts the planQuota key of one profile's reading; "" for a // provider with no plan to read. quotaPrefix string @@ -91,7 +91,7 @@ func profileSentence(prov profileProvider, key string) string { var claudeProfileProvider = profileProvider{ kind: claudeKind, key: claudeProfileKey, label: "Claude Code", quotaPrefix: "profile:", - api: func(c *lichclient.Client) lichclient.ProfileAPI { return c.ClaudeProfiles() }, + api: func(c *backend.Client) backend.ProfileAPI { return c.ClaudeProfiles() }, plugins: true, loginCode: true, words: profileWords{ profileDescKey: "settings.profiles.profileDesc", @@ -109,7 +109,7 @@ var claudeProfileProvider = profileProvider{ var codexProfileProvider = profileProvider{ kind: codexKind, key: codexProfileKey, label: "Codex", quotaPrefix: "codex-profile:", - api: func(c *lichclient.Client) lichclient.ProfileAPI { return c.CodexProfiles() }, + api: func(c *backend.Client) backend.ProfileAPI { return c.CodexProfiles() }, plugins: true, words: profileWords{ profileDescKey: "settings.profiles.profileDesc", @@ -127,7 +127,7 @@ var codexProfileProvider = profileProvider{ var ompProfileProvider = profileProvider{ kind: ompKind, key: ompProfileKey, label: "oh-my-pi", - api: func(c *lichclient.Client) lichclient.ProfileAPI { return c.OMPProfiles() }, + api: func(c *backend.Client) backend.ProfileAPI { return c.OMPProfiles() }, terminalLogin: true, words: profileWords{ profileDescKey: "settings.profiles.profileDesc", @@ -177,12 +177,12 @@ func profileProviderOf(kind string) (profileProvider, bool) { // from any goroutine. type profileBook struct { provider profileProvider - client *lichclient.Client + client *backend.Client invalidate func() quota *planQuota mu sync.Mutex - list []lichclient.Profile + list []backend.Profile images map[string]*avatarImage loaded bool loadedAt time.Time @@ -191,7 +191,7 @@ type profileBook struct { quoted map[string]func() } -func newProfileBook(prov profileProvider, c *lichclient.Client, invalidate func(), quota *planQuota) *profileBook { +func newProfileBook(prov profileProvider, c *backend.Client, invalidate func(), quota *planQuota) *profileBook { return &profileBook{provider: prov, client: c, invalidate: invalidate, quota: quota, quoted: map[string]func(){}} } @@ -203,8 +203,8 @@ func (b *profileBook) reload(ctx context.Context) error { return err } b.mu.Lock() - gone := slices.DeleteFunc(slices.Clone(b.list), func(old lichclient.Profile) bool { - return slices.ContainsFunc(list, func(p lichclient.Profile) bool { return p.Slug == old.Slug }) + gone := slices.DeleteFunc(slices.Clone(b.list), func(old backend.Profile) bool { + return slices.ContainsFunc(list, func(p backend.Profile) bool { return p.Slug == old.Slug }) }) b.list, b.loaded, b.loadedAt = list, true, time.Now() b.images = map[string]*avatarImage{} @@ -222,9 +222,9 @@ func (b *profileBook) reload(ctx context.Context) error { // changed takes a profile-changed event: the profile's name and look, the // login state the event does not carry kept from the list. -func (b *profileBook) changed(p lichclient.Profile) { +func (b *profileBook) changed(p backend.Profile) { b.mu.Lock() - if i := slices.IndexFunc(b.list, func(old lichclient.Profile) bool { return old.Slug == p.Slug }); i >= 0 { + if i := slices.IndexFunc(b.list, func(old backend.Profile) bool { return old.Slug == p.Slug }); i >= 0 { b.list[i].Name, b.list[i].Color, b.list[i].Image = p.Name, p.Color, p.Image b.images[p.Slug] = decodeAvatar(p) } @@ -271,7 +271,7 @@ func (b *profileBook) refreshIfStale(ctx context.Context) { // is quoted outside Settings. The caller holds b.mu. func (b *profileBook) followQuoted() { want := map[string]bool{} - logged := slices.DeleteFunc(slices.Clone(b.list), func(p lichclient.Profile) bool { return !p.LoggedIn }) + logged := slices.DeleteFunc(slices.Clone(b.list), func(p backend.Profile) bool { return !p.LoggedIn }) if b.provider.quotaPrefix != "" && len(logged) > 1 { for _, p := range logged { want[profileQuotaKey(b.provider, p.Slug)] = true @@ -299,20 +299,20 @@ func (b *profileBook) planChanged(slug string) { b.quota.invalidate(profileQuotaKey(b.provider, slug)) } -func (b *profileBook) snapshot() ([]lichclient.Profile, bool) { +func (b *profileBook) snapshot() ([]backend.Profile, bool) { b.mu.Lock() defer b.mu.Unlock() return slices.Clone(b.list), b.loaded } // find is the profile a stored value names; "" is Personal. -func (b *profileBook) find(stored string) (lichclient.Profile, bool) { +func (b *profileBook) find(stored string) (backend.Profile, bool) { slug := effectiveProfile(stored) b.mu.Lock() defer b.mu.Unlock() - i := slices.IndexFunc(b.list, func(p lichclient.Profile) bool { return p.Slug == slug }) + i := slices.IndexFunc(b.list, func(p backend.Profile) bool { return p.Slug == slug }) if i < 0 { - return lichclient.Profile{}, false + return backend.Profile{}, false } return b.list[i], true } @@ -344,9 +344,9 @@ func (b *profileBook) look(stored string) profileLook { } // loggedIn is every profile with a login, in the book's order. -func (b *profileBook) loggedIn() []lichclient.Profile { +func (b *profileBook) loggedIn() []backend.Profile { list, _ := b.snapshot() - return slices.DeleteFunc(list, func(p lichclient.Profile) bool { return !p.LoggedIn }) + return slices.DeleteFunc(list, func(p backend.Profile) bool { return !p.LoggedIn }) } // effectiveProfile is the slug a stored value stands for: an unset value is @@ -371,7 +371,7 @@ func profileChoiceValue(slug, scope string) string { // the providers it holds logins for and, for a provider whose profiles have a // plugin set, how many plugins it carries. "" for a profile nobody is logged in // to. -func profileAccountLine(prov profileProvider, p lichclient.Profile) string { +func profileAccountLine(prov profileProvider, p backend.Profile) string { if !p.LoggedIn { return "" } @@ -408,12 +408,12 @@ func profileInitial(name string) string { // profileUsage is the five-hour reading a Continue in row shows: its text, and // whether the window is spent. "" for a plan that gave no reading. -func profileUsage(plan lichclient.QuotaPlan, ok bool) (text string, limited bool) { +func profileUsage(plan backend.QuotaPlan, ok bool) (text string, limited bool) { if !ok || plan.Status != "ok" { return "", false } - i := slices.IndexFunc(plan.Windows, func(w lichclient.QuotaWindow) bool { return w.Seconds == sessionWindowSeconds }) - w, found := lichclient.QuotaWindow{}, i >= 0 + i := slices.IndexFunc(plan.Windows, func(w backend.QuotaWindow) bool { return w.Seconds == sessionWindowSeconds }) + w, found := backend.QuotaWindow{}, i >= 0 if found { w = plan.Windows[i] } else if w, found = hottestWindow(plan); !found { diff --git a/native/cmd/lich-native/profiles_test.go b/native/cmd/lich-native/profiles_test.go index d20ff0f15..42d561ed8 100644 --- a/native/cmd/lich-native/profiles_test.go +++ b/native/cmd/lich-native/profiles_test.go @@ -3,8 +3,8 @@ package main import ( "testing" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) func TestProfileChoiceValueNamesPersonalOnlyInAProject(t *testing.T) { @@ -23,13 +23,13 @@ func TestProfileChoiceValueNamesPersonalOnlyInAProject(t *testing.T) { func TestProfileAccountLine(t *testing.T) { cases := []struct { - p lichclient.Profile + p backend.Profile want string }{ - {lichclient.Profile{LoggedIn: true, Email: "a@b.c", Plan: "max", Plugins: 3}, "a@b.c · Max · 3 plugins"}, - {lichclient.Profile{LoggedIn: true, Email: "a@b.c", Plugins: 1}, "a@b.c · 1 plugin"}, - {lichclient.Profile{LoggedIn: true, Email: "a@b.c"}, "a@b.c · no plugins"}, - {lichclient.Profile{Email: "left@over"}, ""}, + {backend.Profile{LoggedIn: true, Email: "a@b.c", Plan: "max", Plugins: 3}, "a@b.c · Max · 3 plugins"}, + {backend.Profile{LoggedIn: true, Email: "a@b.c", Plugins: 1}, "a@b.c · 1 plugin"}, + {backend.Profile{LoggedIn: true, Email: "a@b.c"}, "a@b.c · no plugins"}, + {backend.Profile{Email: "left@over"}, ""}, } for _, c := range cases { if got := profileAccountLine(claudeProfileProvider, c.p); got != c.want { @@ -39,7 +39,7 @@ func TestProfileAccountLine(t *testing.T) { } func TestProfileUsageQuotesTheFiveHourWindow(t *testing.T) { - plan := lichclient.QuotaPlan{Status: "ok", Windows: []lichclient.QuotaWindow{ + plan := backend.QuotaPlan{Status: "ok", Windows: []backend.QuotaWindow{ {Seconds: 7 * day, Percent: 90}, {Seconds: 5 * hour, Percent: 12}, }} @@ -50,7 +50,7 @@ func TestProfileUsageQuotesTheFiveHourWindow(t *testing.T) { if text, limited := profileUsage(plan, true); text != "limit" || !limited { t.Errorf("a spent window = %q, %v; want \"limit\", limited", text, limited) } - if text, _ := profileUsage(lichclient.QuotaPlan{Status: "signed-out"}, true); text != "" { + if text, _ := profileUsage(backend.QuotaPlan{Status: "signed-out"}, true); text != "" { t.Errorf("a signed-out plan quoted %q", text) } if text, _ := profileUsage(plan, false); text != "" { @@ -60,7 +60,7 @@ func TestProfileUsageQuotesTheFiveHourWindow(t *testing.T) { func TestProfileBookNamesWhatItDoesNotKnow(t *testing.T) { b := newProfileBook(claudeProfileProvider, nil, func() {}, newPlanQuota(quotaReader(nil), func() {})) - b.list = []lichclient.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Work"}} + b.list = []backend.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Work"}} for stored, want := range map[string]string{"": "Personal", personalProfile: "Personal", "work": "Work", "gone": "gone"} { if got := b.nameOf(stored); got != want { t.Errorf("nameOf(%q) = %q, want %q", stored, got, want) diff --git a/native/cmd/lich-native/profiles_xdg.go b/native/cmd/lich-native/profiles_xdg.go index 5f50e0bd2..05883aa81 100644 --- a/native/cmd/lich-native/profiles_xdg.go +++ b/native/cmd/lich-native/profiles_xdg.go @@ -1,6 +1,6 @@ package main -import "github.com/lichdotdev/lich/native/lichclient" +import "github.com/lichdotdev/lich/native/backend" // opencode, Cursor CLI and Kiro CLI have profiles only where the backend keeps // them (internal/xdgprofile.Providers): xdgProfileProviders lists the same ones. @@ -16,7 +16,7 @@ const ( func xdgProfileProvider(kind, label string, words profileWords) profileProvider { return profileProvider{ kind: kind, key: "provider." + kind + ".profile", label: label, - api: func(c *lichclient.Client) lichclient.ProfileAPI { return c.XDGProfiles(kind) }, + api: func(c *backend.Client) backend.ProfileAPI { return c.XDGProfiles(kind) }, terminalLogin: true, words: words, } } @@ -66,7 +66,7 @@ func xdgProfileProviderOf(kind string) (profileProvider, bool) { return profileProvider{}, false } -func xdgProfileBooks(c *lichclient.Client, invalidate func(), quota *planQuota) map[string]*profileBook { +func xdgProfileBooks(c *backend.Client, invalidate func(), quota *planQuota) map[string]*profileBook { books := map[string]*profileBook{} for _, p := range xdgProfileProviders { books[p.kind] = newProfileBook(p, c, invalidate, quota) diff --git a/native/cmd/lich-native/profiles_xdg_linux_test.go b/native/cmd/lich-native/profiles_xdg_linux_test.go index 0f17b28fa..fa8a5d264 100644 --- a/native/cmd/lich-native/profiles_xdg_linux_test.go +++ b/native/cmd/lich-native/profiles_xdg_linux_test.go @@ -4,7 +4,7 @@ import ( "slices" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -51,7 +51,7 @@ func TestAnXDGProfileLogsInFromATerminal(t *testing.T) { eventually(t, "the terminal card was opened and the dialog closed", func() bool { return !ps.flow.view().open && len(ran) == 1 }) - want := []string{`kiroprofile.CreateProfile["Work"]`, `kiroprofile.LoginCommand["work"]`} + want := []string{`kiroprofile.CreateProfile[{"name":"Work"}]`, `kiroprofile.LoginCommand[{"slug":"work"}]`} if got := b.log(); !slices.Equal(got, want) { t.Errorf("calls = %v, want %v", got, want) } @@ -62,7 +62,7 @@ func TestAnXDGProfileLogsInFromATerminal(t *testing.T) { // database that holds the login, offers none. func TestContinueInIsOfferedWhereTheConversationCanMove(t *testing.T) { m := newModel(nil, func() {}) - logged := []lichclient.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}, {Slug: "work", Name: "Work", LoggedIn: true}} + logged := []backend.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}, {Slug: "work", Name: "Work", LoggedIn: true}} for _, kind := range []string{opencodeKind, cursorKind, kiroKind} { m.xdgProfiles[kind].list = logged } diff --git a/native/cmd/lich-native/project_events_test.go b/native/cmd/lich-native/project_events_test.go index 69e469683..ac8dafc3a 100644 --- a/native/cmd/lich-native/project_events_test.go +++ b/native/cmd/lich-native/project_events_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // rpcLog serves every call with answer (nil for none), failing the ones whose @@ -23,7 +23,7 @@ type rpcLog struct { answer map[string]any } -func (l *rpcLog) client(t *testing.T) *lichclient.Client { +func (l *rpcLog) client(t *testing.T) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) @@ -87,7 +87,7 @@ func TestAskCloseTakesNoTabItself(t *testing.T) { if !slices.Equal(sessions, []string{"a1", "a2"}) { t.Errorf("sessions %v, want the project's two", sessions) } - if got := rpc.seen(); !slices.Equal(got, []string{`/rpc/store.CloseProject ["a"]`}) { + if got := rpc.seen(); !slices.Equal(got, []string{`/rpc/store.CloseProject [{"id":"a"}]`}) { t.Errorf("calls %q, want one CloseProject", got) } if got := tabIDs(m); !slices.Equal(got, []string{"a", "b", "c"}) { @@ -181,8 +181,8 @@ func TestADragOverAnOlderOneKeepsItsOwnPendingOrder(t *testing.T) { } } -func closedRows() []lichclient.RecentProject { - return []lichclient.RecentProject{{ID: "x", Name: "ex", Path: "/p/ex"}, {ID: "y", Name: "why", Path: "/p/why"}} +func closedRows() []backend.RecentProject { + return []backend.RecentProject{{ID: "x", Name: "ex", Path: "/p/ex"}, {ID: "y", Name: "why", Path: "/p/why"}} } func waitFor(t *testing.T, what string, ok func() bool) { diff --git a/native/cmd/lich-native/projects.go b/native/cmd/lich-native/projects.go index 9d838a382..cc7885a94 100644 --- a/native/cmd/lich-native/projects.go +++ b/native/cmd/lich-native/projects.go @@ -7,7 +7,7 @@ import ( "slices" "strings" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // homeScreen is the route saved for Home. @@ -23,7 +23,7 @@ func screenPath(projectID string) string { } // saveLastScreen saves the screen the next window opens on, a global setting. -func saveLastScreen(ctx context.Context, c *lichclient.Client, projectID string) error { +func saveLastScreen(ctx context.Context, c *backend.Client, projectID string) error { return c.SetSetting(ctx, lastScreenKey, "", screenPath(projectID)) } @@ -58,7 +58,7 @@ func (m *model) projectTabs() (tabs []projectTab, active string) { return tabs, active } -func sessionIDs(p lichclient.Project) []string { +func sessionIDs(p backend.Project) []string { ids := make([]string, len(p.Sessions)) for i, s := range p.Sessions { ids[i] = s.ID @@ -85,8 +85,8 @@ func (m *model) onHome() bool { } // projectByID is the open project with id, nil when none is. Callers hold m.mu. -func (m *model) projectByID(id string) *lichclient.Project { - if i := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == id }); i >= 0 { +func (m *model) projectByID(id string) *backend.Project { + if i := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == id }); i >= 0 { return &m.projects[i] } return nil @@ -101,7 +101,7 @@ const ( // ensureHome opens the home directory's project with a shell in it when the // workspace does not hold it, and returns the workspace as it stands after. -func (m *model) ensureHome(ctx context.Context, ps []lichclient.Project) ([]lichclient.Project, error) { +func (m *model) ensureHome(ctx context.Context, ps []backend.Project) ([]backend.Project, error) { home, added, err := m.addHome(ctx, ps) if err != nil || !added { return ps, err @@ -114,7 +114,7 @@ func (m *model) ensureHome(ctx context.Context, ps []lichclient.Project) ([]lich // addHome opens the home directory's project, with no session, when ps does // not hold it, and reports whether it did. -func (m *model) addHome(ctx context.Context, ps []lichclient.Project) (lichclient.Project, bool, error) { +func (m *model) addHome(ctx context.Context, ps []backend.Project) (backend.Project, bool, error) { home, err := m.client.HomeProject(ctx) if err != nil { return home, false, err @@ -122,7 +122,7 @@ func (m *model) addHome(ctx context.Context, ps []lichclient.Project) (lichclien m.mu.Lock() m.homeID = home.ID m.mu.Unlock() - if slices.ContainsFunc(ps, func(p lichclient.Project) bool { return p.ID == home.ID }) { + if slices.ContainsFunc(ps, func(p backend.Project) bool { return p.ID == home.ID }) { return home, false, nil } return home, true, m.client.AddProject(ctx, home.ID, home.Name, home.Path) @@ -150,7 +150,7 @@ func (m *model) openHome(ctx context.Context) error { func (m *model) runningIn(projectID string) int { m.mu.Lock() defer m.mu.Unlock() - i := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == projectID }) + i := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == projectID }) if i < 0 { return 0 } @@ -171,7 +171,7 @@ type closedProject struct { func (m *model) closeProject(id string) (closedProject, bool) { m.mu.Lock() defer m.mu.Unlock() - i := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == id }) + i := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == id }) if i < 0 { return closedProject{}, false } @@ -191,7 +191,7 @@ func (m *model) closeProject(id string) (closedProject, bool) { // shownOrder is the open projects as the strip draws them: the drag the // backend has not confirmed yet, else the stored order. Callers hold m.mu. -func (m *model) shownOrder() []lichclient.Project { +func (m *model) shownOrder() []backend.Project { if m.pendingOrder == nil { return m.projects } @@ -199,15 +199,15 @@ func (m *model) shownOrder() []lichclient.Project { } // orderedBy is ps in the order of ids, a project ids does not name last. -func orderedBy(ps []lichclient.Project, ids []string) []lichclient.Project { +func orderedBy(ps []backend.Project, ids []string) []backend.Project { out := slices.Clone(ps) - rank := func(p lichclient.Project) int { + rank := func(p backend.Project) int { if i := slices.Index(ids, p.ID); i >= 0 { return i } return len(ids) } - slices.SortStableFunc(out, func(a, b lichclient.Project) int { return rank(a) - rank(b) }) + slices.SortStableFunc(out, func(a, b backend.Project) int { return rank(a) - rank(b) }) return out } @@ -267,7 +267,7 @@ func (m *model) askClose(ctx context.Context, id string) ([]string, error) { m.mu.Unlock() return nil, errHomeIsPermanent } - i := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == id }) + i := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == id }) var sessions []string if i >= 0 { sessions = sessionIDs(m.projects[i]) diff --git a/native/cmd/lich-native/projects_test.go b/native/cmd/lich-native/projects_test.go index 2929ff59c..13a5dd422 100644 --- a/native/cmd/lich-native/projects_test.go +++ b/native/cmd/lich-native/projects_test.go @@ -12,14 +12,15 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) -func threeProjects() []lichclient.Project { - return []lichclient.Project{ - {ID: "a", Name: "alpha", ActiveSessionID: "a1", Sessions: []lichclient.Session{{ID: "a1", Label: "A one"}, {ID: "a2", Label: "A two"}}}, - {ID: "b", Name: "beta", ActiveSessionID: "b1", Sessions: []lichclient.Session{{ID: "b1", Label: "B one"}}}, - {ID: "c", Name: "gamma", ActiveSessionID: "c1", Sessions: []lichclient.Session{{ID: "c1", Label: "C one"}}}, +func threeProjects() []backend.Project { + return []backend.Project{ + {ID: "a", Name: "alpha", ActiveSessionID: "a1", Sessions: []backend.Session{{ID: "a1", Label: "A one"}, {ID: "a2", Label: "A two"}}}, + {ID: "b", Name: "beta", ActiveSessionID: "b1", Sessions: []backend.Session{{ID: "b1", Label: "B one"}}}, + {ID: "c", Name: "gamma", ActiveSessionID: "c1", Sessions: []backend.Session{{ID: "c1", Label: "C one"}}}, } } @@ -33,7 +34,7 @@ func modelOn(active string) *model { func report(t *testing.T, m *model, id, state string) { t.Helper() data, _ := json.Marshal(statusEvent{ID: id, State: state}) - if _, err := m.status.apply(lichclient.Event{Name: statusEventName, Data: data}, time.Now()); err != nil { + if _, err := m.status.apply(backend.Event{Name: api.SessionStatusEvent.Name, Data: data}, time.Now()); err != nil { t.Fatal(err) } } @@ -142,7 +143,7 @@ func TestDismissReadsAFinishedTurn(t *testing.T) { } select { case got := <-reads: - if got != `/rpc/store.SetSessionUnread ["b1",false]` { + if got != `/rpc/store.SetSessionUnread [{"sessionId":"b1","unread":false}]` { t.Errorf("called %q, want b1 marked read", got) } case <-time.After(5 * time.Second): @@ -164,22 +165,22 @@ func TestSaveLastScreenWritesTheWebsRoute(t *testing.T) { } } want := []string{ - `/rpc/store.SetSetting ["window.lastScreen","","/projects/b"]`, - `/rpc/store.SetSetting ["window.lastScreen","","/"]`, + `/rpc/store.SetSetting [{"key":"window.lastScreen","projectId":"","value":"/projects/b"}]`, + `/rpc/store.SetSetting [{"key":"window.lastScreen","projectId":"","value":"/"}]`, } if !slices.Equal(got, want) { t.Errorf("wrote %q, want %q", got, want) } } -func clientOf(srv *httptest.Server) *lichclient.Client { +func clientOf(srv *httptest.Server) *backend.Client { u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func TestOpenHomeBringsTheHomeProjectBack(t *testing.T) { - home := lichclient.Project{ID: "home", Name: "~", Path: "/home/me"} + home := backend.Project{ID: "home", Name: "~", Path: "/home/me"} state := threeProjects() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var result any @@ -206,7 +207,7 @@ func TestOpenHomeBringsTheHomeProjectBack(t *testing.T) { } func TestOpenHomeSeedsNoSession(t *testing.T) { - home := lichclient.Project{ID: "home", Name: "~", Path: "/home/me"} + home := backend.Project{ID: "home", Name: "~", Path: "/home/me"} state := threeProjects() seeded := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/native/cmd/lich-native/providers_book.go b/native/cmd/lich-native/providers_book.go index 15cd98b8e..e644263d6 100644 --- a/native/cmd/lich-native/providers_book.go +++ b/native/cmd/lich-native/providers_book.go @@ -7,7 +7,7 @@ import ( "slices" "sync" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) type providerInfo struct { @@ -67,7 +67,7 @@ func (m providersMachine) skipLevel(kind string) string { return skipLevelOf(m.globals[providerSkipKey(kind, false)] == "true", m.globals[providerSkipKey(kind, true)] == "true") } -func providerFrom(d lichclient.DetectedProvider, enabled bool) providerInfo { +func providerFrom(d backend.DetectedProvider, enabled bool) providerInfo { return providerInfo{ launchProvider: launchProvider{id: d.ID, name: d.Name, installed: d.Installed, enabled: enabled}, binary: d.Binary, source: d.Source, docs: d.Docs, @@ -77,7 +77,7 @@ func providerFrom(d lichclient.DetectedProvider, enabled bool) providerInfo { // readProvidersMachine is the one read of the providers: detection, each // provider's enabled flag and the global values of the keys its screen reads, // and the default and profiles of each project in projectIDs. -func readProvidersMachine(ctx context.Context, c *lichclient.Client, projectIDs []string) (providersMachine, error) { +func readProvidersMachine(ctx context.Context, c *backend.Client, projectIDs []string) (providersMachine, error) { detected, err := c.DetectProviders(ctx) if err != nil { return providersMachine{}, err @@ -113,7 +113,7 @@ func readProvidersMachine(ctx context.Context, c *lichclient.Client, projectIDs // readProfiles reads each project's profile of every detected provider with // profiles. -func (m *providersMachine) readProfiles(ctx context.Context, c *lichclient.Client, projectIDs []string) error { +func (m *providersMachine) readProfiles(ctx context.Context, c *backend.Client, projectIDs []string) error { var profileKeys []string for _, p := range m.list { if prov, ok := profileProviderOf(p.id); ok { @@ -158,7 +158,7 @@ func (m providersMachine) projectProfile(kind, projectID string) projectProfile // Settings › Providers and Sandbox's rows all read it. Whoever writes // something it reads says so with changed, once the backend has the write. type providerBook struct { - client *lichclient.Client + client *backend.Client invalidate func() mu sync.Mutex @@ -171,7 +171,7 @@ type providerBook struct { reading, again bool } -func newProviderBook(c *lichclient.Client, invalidate func()) *providerBook { +func newProviderBook(c *backend.Client, invalidate func()) *providerBook { return &providerBook{client: c, invalidate: invalidate} } @@ -265,7 +265,7 @@ func (b *providerBook) readUntilQuiet(ctx context.Context) { // default of one it has not read yet is what the state it came in with says, // until the next read. Its profiles are not in the state: it returns the // projects it had not seen, for readProfiles. -func (b *providerBook) noteProjects(projects []lichclient.Project) (fresh []string) { +func (b *providerBook) noteProjects(projects []backend.Project) (fresh []string) { b.mu.Lock() defer b.mu.Unlock() b.ids = b.ids[:0] diff --git a/native/cmd/lich-native/providers_book_test.go b/native/cmd/lich-native/providers_book_test.go index 6dbb628dc..c35efe76a 100644 --- a/native/cmd/lich-native/providers_book_test.go +++ b/native/cmd/lich-native/providers_book_test.go @@ -4,13 +4,13 @@ import ( "context" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) // launchRig is the window's providers wiring: a model, and the Settings // screen whose writes have to reach it. -func launchRig(t *testing.T, f *sidebarBackend, c *lichclient.Client) (*model, *settingsScreen) { +func launchRig(t *testing.T, f *sidebarBackend, c *backend.Client) (*model, *settingsScreen) { t.Helper() ctx := context.Background() m := newModel(c, func() {}) @@ -22,11 +22,11 @@ func launchRig(t *testing.T, f *sidebarBackend, c *lichclient.Client) (*model, * return m, s } -func claudeDetected(installed bool) []lichclient.DetectedProvider { - return []lichclient.DetectedProvider{{ID: "claude", Name: "Claude Code", Installed: installed}} +func claudeDetected(installed bool) []backend.DetectedProvider { + return []backend.DetectedProvider{{ID: "claude", Name: "Claude Code", Installed: installed}} } -func setDetected(f *sidebarBackend, list []lichclient.DetectedProvider) { +func setDetected(f *sidebarBackend, list []backend.DetectedProvider) { f.mu.Lock() defer f.mu.Unlock() f.detected = list @@ -57,7 +57,7 @@ func TestCheckingThePathAgainMovesTheImplicitSessionKind(t *testing.T) { } func twoInstalled(f *sidebarBackend) { - setDetected(f, []lichclient.DetectedProvider{ + setDetected(f, []backend.DetectedProvider{ {ID: "claude", Name: "Claude Code", Installed: true}, {ID: "codex", Name: "Codex", Installed: true}, }) @@ -71,7 +71,7 @@ func TestAProjectDefaultIsOneValueEveryReaderSees(t *testing.T) { twoInstalled(f) m, s := launchRig(t, f, c) m.mu.Lock() - m.projects = []lichclient.Project{{ID: "p", Name: "p"}} + m.projects = []backend.Project{{ID: "p", Name: "p"}} m.providers.noteProjects(m.projects) m.mu.Unlock() s.scope = "p" @@ -117,7 +117,7 @@ func TestAProjectOpenedAfterTheReadBringsItsDefault(t *testing.T) { twoInstalled(f) m, _ := launchRig(t, f, c) m.mu.Lock() - m.projects = []lichclient.Project{{ID: "late", DefaultProvider: "codex"}} + m.projects = []backend.Project{{ID: "late", DefaultProvider: "codex"}} m.activeProject = "late" m.providers.noteProjects(m.projects) m.mu.Unlock() diff --git a/native/cmd/lich-native/pulls.go b/native/cmd/lich-native/pulls.go index cc9a43b40..ad13f4b7f 100644 --- a/native/cmd/lich-native/pulls.go +++ b/native/cmd/lich-native/pulls.go @@ -13,7 +13,7 @@ import ( "gioui.org/unit" "gioui.org/widget" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -76,7 +76,7 @@ type pullsScreen struct { // spans are the islands as the last layout placed them, for the tab // strip to merge the active tab into the one it sits over. spans []islandSpan - project lichclient.Project + project backend.Project selected int // the PR in view, 0 for none yet // branchPR is the PR of the checkout's branch, which the screen opens on // until a row is chosen. @@ -109,7 +109,7 @@ type pullsScreen struct { // checkout of its head branch, the window's active one when it sits there and // the first otherwise, "" when none does. target keys its comment batch: that checkout, shared with its // dock, or the PR's page while the branch has none. -func (p *pullsScreen) reviewSession(d lichclient.PRDetail) (id, target string) { +func (p *pullsScreen) reviewSession(d backend.PRDetail) (id, target string) { wt, ok := p.checkedOut(d.HeadRefName) if !ok { return "", d.URL @@ -134,7 +134,7 @@ func newPullsScreen(store *pullsStore, root *ui.Root) *pullsScreen { } // show opens the screen on project, at its checkout's PR when it has one. -func (p *pullsScreen) show(project lichclient.Project, branch string, branchPR int) { +func (p *pullsScreen) show(project backend.Project, branch string, branchPR int) { if p.project.ID != project.ID { p.selected = 0 } @@ -219,11 +219,11 @@ func pullsIsland(gtx C, th *ui.Theme, bg color.NRGBA, squareNW bool, w func(gtx } // checkedOut is the checkout holding branch, ok false when none does. -func (p *pullsScreen) checkedOut(branch string) (lichclient.Worktree, bool) { +func (p *pullsScreen) checkedOut(branch string) (backend.Worktree, bool) { for _, c := range p.store.checkoutsOf(p.project.Path) { if c.Name == branch { return c, true } } - return lichclient.Worktree{}, false + return backend.Worktree{}, false } diff --git a/native/cmd/lich-native/pulls_actions.go b/native/cmd/lich-native/pulls_actions.go index f05747c0a..42c7055e3 100644 --- a/native/cmd/lich-native/pulls_actions.go +++ b/native/cmd/lich-native/pulls_actions.go @@ -13,8 +13,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -57,7 +57,7 @@ func (u *window) showPulls(number int) { // checkoutPR is the active session's branch and its open PR, the project's // own checkout's without a session. -func (u *window) checkoutPR(p lichclient.Project) (string, int) { +func (u *window) checkoutPR(p backend.Project) (string, int) { if sv, ok := u.model.activeSession(); ok { if sv.pr != nil { return sv.git.Branch, sv.pr.Number @@ -91,7 +91,7 @@ func (u *window) applyPulls(act pullsAction) { if act.create { go func() { if err := u.client.CreatePullRequest(u.ctx, u.checkoutPath(path)); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pullsEmptyState.openFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pullsEmptyState.openFailed", i18n.Args{"error": backend.ErrorText(err)})}) } }() } @@ -136,7 +136,7 @@ func (u *window) approvePR(path string, number int) { u.pulls.view.setBusy("approve") defer u.pulls.view.setBusy("") if err := u.client.SubmitReview(u.ctx, path, number, "approve", "", "", nil); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pullRequestView.reviewFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pullRequestView.reviewFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.pullRequestView.approvedToast", i18n.Args{"number": number})}) @@ -146,12 +146,12 @@ func (u *window) approvePR(path string, number int) { // the session's prompt: a checkout already holding the branch is reused, its live // session brought up or a new one opened; else the PR is checked out into a // new worktree. The checkouts are read fresh, never from what drew the button. -func (u *window) openPRSession(project lichclient.Project, d lichclient.PRDetail, prompt string) { +func (u *window) openPRSession(project backend.Project, d backend.PRDetail, prompt string) { u.pulls.view.setBusy("session") defer u.pulls.view.setBusy("") id, err := u.prSession(project, d) if err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.openSessionFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.openSessionFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } u.pulls.hide() @@ -161,12 +161,12 @@ func (u *window) openPRSession(project lichclient.Project, d lichclient.PRDetail } } -func (u *window) prSession(project lichclient.Project, d lichclient.PRDetail) (string, error) { +func (u *window) prSession(project backend.Project, d backend.PRDetail) (string, error) { checkouts, err := u.pulls.store.freshCheckouts(project.Path) if err != nil { return "", err } - i := slices.IndexFunc(checkouts, func(c lichclient.Worktree) bool { return c.Name == d.HeadRefName }) + i := slices.IndexFunc(checkouts, func(c backend.Worktree) bool { return c.Name == d.HeadRefName }) if i < 0 { wt, err := u.client.CreateWorktreeFromPR(u.ctx, project.Path, project.ID, d.Number) if err != nil { @@ -188,14 +188,14 @@ func (u *window) prSession(project lichclient.Project, d lichclient.PRDetail) (s } if restored == nil { return u.addWorktreeSession(worktreeRequest{project: project, kind: u.model.newSessionKind()}, - &lichclient.Worktree{Name: filepath.Base(existing.Path), Path: existing.Path}, "") + &backend.Worktree{Name: filepath.Base(existing.Path), Path: existing.Path}, "") } return restored.ID, u.showNewSession(project.ID, restored.ID) } // newSessionAt opens a session of the kind a new one spawns in the project's // own checkout and puts it on screen. -func (u *window) newSessionAt(project lichclient.Project, path string) (string, error) { +func (u *window) newSessionAt(project backend.Project, path string) (string, error) { kind := u.model.newSessionKind() id := strings.ToLower(rand.Text()) label := fmt.Sprintf("%s %d", strings.ToUpper(kind[:1])+kind[1:], project.NextSeq+1) @@ -209,11 +209,11 @@ func (u *window) newSessionAt(project lichclient.Project, path string) (string, // empty subject), tells the agents in the merged branch's checkout, and // offers to remove that checkout when it is a worktree. It reports whether // the merge went through. -func (u *window) mergePR(project lichclient.Project, d lichclient.PRDetail, method string, admin bool, subject, body string) bool { +func (u *window) mergePR(project backend.Project, d backend.PRDetail, method string, admin bool, subject, body string) bool { u.pulls.view.setBusy("merge") defer u.pulls.view.setBusy("") if err := u.client.MergePullRequest(u.ctx, project.Path, d.Number, method, subject, body, admin); err != nil { - text := lichclient.ErrorText(err) + text := backend.ErrorText(err) if note := mergeRuleNote(d, u.pulls.store.branchRules(project.Path, d.BaseRefName)); note != "" { text += " " + note } @@ -223,14 +223,14 @@ func (u *window) mergePR(project lichclient.Project, d lichclient.PRDetail, meth merged := ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.pulls.merged", i18n.Args{"number": d.Number, "base": d.BaseRefName})} checkouts, err := u.pulls.store.freshCheckouts(project.Path) u.logErr("list checkouts", err) - i := slices.IndexFunc(checkouts, func(c lichclient.Worktree) bool { return c.Name == d.HeadRefName }) + i := slices.IndexFunc(checkouts, func(c backend.Worktree) bool { return c.Name == d.HeadRefName }) if i < 0 { u.toaster.Show(merged) return true } wt := checkouts[i] if err := u.client.AnnounceMerge(u.ctx, wt.Path, d.Number, d.Title, d.HeadRefName, d.BaseRefName); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.announceFailed", i18n.Args{"name": filepath.Base(wt.Path), "error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.announceFailed", i18n.Args{"name": filepath.Base(wt.Path), "error": backend.ErrorText(err)})}) } if wt.Path != project.Path { merged.Duration = cleanupToastFor @@ -242,14 +242,14 @@ func (u *window) mergePR(project lichclient.Project, d lichclient.PRDetail, meth // removePRWorktree removes a PR's worktree: the refusals first, the // question about running sessions last, a click the refusals would make moot. -func (u *window) removePRWorktree(project lichclient.Project, path string) { +func (u *window) removePRWorktree(project backend.Project, path string) { name := filepath.Base(path) checkouts, err := u.pulls.store.freshCheckouts(project.Path) if err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.removeFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.removeFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } - if !slices.ContainsFunc(checkouts, func(c lichclient.Worktree) bool { return c.Path == path }) { + if !slices.ContainsFunc(checkouts, func(c backend.Worktree) bool { return c.Path == path }) { u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.pulls.alreadyRemoved", i18n.Args{"name": name})}) return } @@ -277,7 +277,7 @@ func (u *window) removePRWorktree(project lichclient.Project, path string) { // removeCheckout discards the sessions rooted in path, so none survives to // offer a resume in a directory that is gone, then removes the worktree. // force and adoptedAck are RemoveWorktree's. -func (u *window) removeCheckout(project lichclient.Project, path string, force, adoptedAck bool) { +func (u *window) removeCheckout(project backend.Project, path string, force, adoptedAck bool) { ids, _, _ := u.model.sessionsAt(project.ID, path) for _, id := range ids { u.parkSession(id) @@ -286,7 +286,7 @@ func (u *window) removeCheckout(project lichclient.Project, path string, force, } u.logErr("purge worktree sessions", u.client.PurgeWorktreeSessions(u.ctx, project.ID, path)) if err := u.client.RemoveWorktree(u.ctx, project.Path, path, force, adoptedAck); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.removeFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.removeFailed", i18n.Args{"error": backend.ErrorText(err)})}) } else { u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.pulls.removed", i18n.Args{"name": filepath.Base(path)})}) } @@ -300,7 +300,7 @@ func (u *window) removeCheckout(project lichclient.Project, path string, force, func (m *model) sessionsAt(projectID, path string) (ids []string, pinned bool, running int) { m.mu.Lock() defer m.mu.Unlock() - i := slices.IndexFunc(m.projects, func(p lichclient.Project) bool { return p.ID == projectID }) + i := slices.IndexFunc(m.projects, func(p backend.Project) bool { return p.ID == projectID }) if i < 0 { return nil, false, 0 } @@ -337,14 +337,14 @@ type removeConfirm struct { // mu guards the question, which the removal asks off the frame. mu sync.Mutex open bool - project lichclient.Project + project backend.Project path string running int cancel widget.Clickable confirm widget.Clickable } -func (c *removeConfirm) ask(project lichclient.Project, path string, running int) { +func (c *removeConfirm) ask(project backend.Project, path string, running int) { c.mu.Lock() c.open, c.project, c.path, c.running = true, project, path, running c.mu.Unlock() diff --git a/native/cmd/lich-native/pulls_comment_edit.go b/native/cmd/lich-native/pulls_comment_edit.go index 400dc5932..2dbbdab56 100644 --- a/native/cmd/lich-native/pulls_comment_edit.go +++ b/native/cmd/lich-native/pulls_comment_edit.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -47,7 +47,7 @@ type commentEdit struct { body string } -func threadTarget(t lichclient.PRReviewThread, c lichclient.PRThreadComment) commentTarget { +func threadTarget(t backend.PRReviewThread, c backend.PRThreadComment) commentTarget { return commentTarget{ kind: threadComment, id: c.ID, body: c.Body, canEdit: c.CanEdit, canDelete: c.CanDelete, lastInThread: len(t.Comments) == 1, @@ -111,7 +111,7 @@ func (v *pullView) commentHead(gtx C, th *ui.Theme, p *pullsScreen, t commentTar // threadComment is one comment of a thread: its byline over its body, or over // the box editing it. -func (v *pullView) threadComment(gtx C, th *ui.Theme, p *pullsScreen, t commentTarget, c lichclient.PRThreadComment, act *pullsAction, now time.Time) D { +func (v *pullView) threadComment(gtx C, th *ui.Theme, p *pullsScreen, t commentTarget, c backend.PRThreadComment, act *pullsAction, now time.Time) D { editing := v.talk.editing[t.key()] return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { @@ -173,7 +173,7 @@ func (u *window) editComment(path string, number int, e commentEdit) { v := &u.pulls.view key := e.target.key() u.prWrite("edit:"+key, func(err error) string { - return i18n.T("pulls.reviewThread.editFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + return i18n.T("pulls.reviewThread.editFailed", i18n.Args{"error": backend.ErrorText(err)}) }, func() error { var err error switch e.target.kind { @@ -196,7 +196,7 @@ func (u *window) editComment(path string, number int, e commentEdit) { // has no delete. func (u *window) deleteComment(path string, number int, t commentTarget) { u.prWrite("delete:"+t.key(), func(err error) string { - return i18n.T("pulls.reviewThread.deleteFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + return i18n.T("pulls.reviewThread.deleteFailed", i18n.Args{"error": backend.ErrorText(err)}) }, func() error { if t.kind == prComment { return u.client.DeletePullRequestComment(u.ctx, path, number, t.id) diff --git a/native/cmd/lich-native/pulls_conversation.go b/native/cmd/lich-native/pulls_conversation.go index 6b929cb09..70a3366ec 100644 --- a/native/cmd/lich-native/pulls_conversation.go +++ b/native/cmd/lich-native/pulls_conversation.go @@ -9,8 +9,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -118,7 +118,7 @@ func (e *pendingEdit) clicks(gtx C, pending *pendingReview) { // conversationRows is PullsConversation: the pending review, the timeline, // the resolved threads behind their count, and the box for a comment on the // pull request itself. -func (v *pullView) conversationRows(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction) []layout.Widget { +func (v *pullView) conversationRows(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) []layout.Widget { read := p.store.conversation(p.project.Path, d.Number) switch { case read.val == nil && read.err != "": @@ -161,7 +161,7 @@ func (v *pullView) conversationRows(gtx C, th *ui.Theme, p *pullsScreen, d lichc return append(rows, func(gtx C) D { return v.commentBox(gtx, th, act) }) } -func verdictLine(r lichclient.PRReview) string { +func verdictLine(r backend.PRReview) string { if v, ok := reviewVerdict(r.State); ok { return v } @@ -212,7 +212,7 @@ func byline(gtx C, th *ui.Theme, author, verdict, date string, now time.Time) D // threadCard is ReviewThread: the anchor and its badges over GitHub's hunk, // the comments, then Reply and Resolve; a resolved thread starts folded. -func (v *pullView) threadCard(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, t lichclient.PRReviewThread, act *pullsAction, now time.Time) D { +func (v *pullView) threadCard(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, t backend.PRReviewThread, act *pullsAction, now time.Time) D { return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(3)}.Layout(gtx, func(gtx C) D { return v.threadBox(gtx, th, p, d, t, act, now) }) @@ -220,7 +220,7 @@ func (v *pullView) threadCard(gtx C, th *ui.Theme, p *pullsScreen, d lichclient. // threadBox is the thread's card itself, folded to its header or open; the // Conversation tab and the diff share its fold. -func (v *pullView) threadBox(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, t lichclient.PRReviewThread, act *pullsAction, now time.Time) D { +func (v *pullView) threadBox(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, t backend.PRReviewThread, act *pullsAction, now time.Time) D { s := &v.talk fold := boolOf(s.folds, t.ID) if _, seen := s.foldClicks[t.ID]; !seen { @@ -250,7 +250,7 @@ func (v *pullView) threadBox(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.P }) } -func threadHeader(gtx C, th *ui.Theme, t lichclient.PRReviewThread, open bool) D { +func threadHeader(gtx C, th *ui.Theme, t backend.PRReviewThread, open bool) D { chevron := icPullRight if open { chevron = icPullDown @@ -279,7 +279,7 @@ func threadHeader(gtx C, th *ui.Theme, t lichclient.PRReviewThread, open bool) D return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) } -func (v *pullView) threadBody(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, t lichclient.PRReviewThread, act *pullsAction, now time.Time) D { +func (v *pullView) threadBody(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, t backend.PRReviewThread, act *pullsAction, now time.Time) D { var rows []layout.FlexChild if len(t.Comments) > 0 && t.Comments[0].DiffHunk != "" { rows = append(rows, layout.Rigid(func(gtx C) D { @@ -333,7 +333,7 @@ func hunkTail(gtx C, th *ui.Theme, hunk string) D { } // threadActions is Reply and Resolve, or the reply box while one is written. -func (v *pullView) threadActions(gtx C, th *ui.Theme, d lichclient.PRDetail, t lichclient.PRReviewThread, act *pullsAction) D { +func (v *pullView) threadActions(gtx C, th *ui.Theme, d backend.PRDetail, t backend.PRReviewThread, act *pullsAction) D { s := &v.talk reply, resolve := clickableOf(s.replyClicks, t.ID), clickableOf(s.resolveClick, t.ID) if reply.Clicked(gtx) { @@ -362,7 +362,7 @@ func (v *pullView) threadActions(gtx C, th *ui.Theme, d lichclient.PRDetail, t l layout.Rigid(replyB.Layout), ui.Gap(2), layout.Rigid(resolveB.Layout)) } -func (v *pullView) replyBox(gtx C, th *ui.Theme, d lichclient.PRDetail, t lichclient.PRReviewThread, act *pullsAction) D { +func (v *pullView) replyBox(gtx C, th *ui.Theme, d backend.PRDetail, t backend.PRReviewThread, act *pullsAction) D { s := &v.talk ed := s.editorOf(t.ID) send, cancel := clickableOf(s.sendClicks, t.ID), clickableOf(s.cancelClicks, t.ID) @@ -464,7 +464,7 @@ func (v *pullView) pendingRows(th *ui.Theme, r *pendingReview) []layout.Widget { // pendingRow is one line comment of the review being written, with Edit and // Discard, or the box rewording it. -func (v *pullView) pendingRow(gtx C, th *ui.Theme, i int, c lichclient.ReviewComment) D { +func (v *pullView) pendingRow(gtx C, th *ui.Theme, i int, c backend.ReviewComment) D { s := &v.talk discard, edit := clickableAt(s.discards, i), clickableAt(s.edits, i) editing := s.pendingEdit.open && s.pendingEdit.index == i diff --git a/native/cmd/lich-native/pulls_conversation_test.go b/native/cmd/lich-native/pulls_conversation_test.go index 5e0557c24..51609ec93 100644 --- a/native/cmd/lich-native/pulls_conversation_test.go +++ b/native/cmd/lich-native/pulls_conversation_test.go @@ -14,32 +14,32 @@ import ( "gioui.org/op" "gioui.org/text" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) -func talkFixture() *lichclient.PRConversation { - return &lichclient.PRConversation{ - Reviews: []lichclient.PRReview{{DatabaseID: 30, Author: "rev", State: "CHANGES_REQUESTED", Body: "A couple of things.", Date: "2026-01-02T00:00:00Z", CanEdit: true}}, - Comments: []lichclient.PRComment{{ID: 20, Author: "octo", Body: "Thanks!", Date: "2026-01-01T00:00:00Z", CanEdit: true, CanDelete: true}}, - Threads: []lichclient.PRReviewThread{ - {ID: "T1", Path: "internal/project/pr.go", Line: 61, StartLine: 58, IsOutdated: true, Comments: []lichclient.PRThreadComment{ +func talkFixture() *backend.PRConversation { + return &backend.PRConversation{ + Reviews: []backend.PRReview{{DatabaseID: 30, Author: "rev", State: "CHANGES_REQUESTED", Body: "A couple of things.", Date: "2026-01-02T00:00:00Z", CanEdit: true}}, + Comments: []backend.PRComment{{ID: 20, Author: "octo", Body: "Thanks!", Date: "2026-01-01T00:00:00Z", CanEdit: true, CanDelete: true}}, + Threads: []backend.PRReviewThread{ + {ID: "T1", Path: "internal/project/pr.go", Line: 61, StartLine: 58, IsOutdated: true, Comments: []backend.PRThreadComment{ {ID: 1, Author: "rev", Body: "Why not cap this?", Date: "2026-01-02T00:00:00Z", DiffHunk: "@@ -1,3 +1,4 @@\n ctx\n-old\n+new", CanEdit: true, CanDelete: true}, }}, - {ID: "T2", Path: "a.go", Line: 3, IsResolved: true, Comments: []lichclient.PRThreadComment{{ID: 2, Author: "rev", Body: "ok", Date: "2026-01-01T00:00:00Z"}}}, + {ID: "T2", Path: "a.go", Line: 3, IsResolved: true, Comments: []backend.PRThreadComment{{ID: 2, Author: "rev", Body: "ok", Date: "2026-01-01T00:00:00Z"}}}, }, } } // layoutTalk lays the Conversation tab out once and answers the frame; // prepare, when set, puts the tab's widgets in a state first. -func layoutTalk(t *testing.T, conv *lichclient.PRConversation, prepare func(*talkState)) (*op.Ops, image.Point) { +func layoutTalk(t *testing.T, conv *backend.PRConversation, prepare func(*talkState)) (*op.Ops, image.Point) { t.Helper() stub := &rpcStub{answer: func(string, []json.RawMessage) (any, int) { return conv, http.StatusOK }} p := newPullsScreen(newPullsStore(context.Background(), stub.client(t), func() {}), new(ui.Root)) - p.project, p.selected = lichclient.Project{Path: "/repo"}, 7 - d := lichclient.PRDetail{Number: 7, URL: "https://github.com/o/r/pull/7"} + p.project, p.selected = backend.Project{Path: "/repo"}, 7 + d := backend.PRDetail{Number: 7, URL: "https://github.com/o/r/pull/7"} p.view.pendingOf(d.URL).add("a.go", "3-5", "Rename this.") if prepare != nil { prepare(&p.view.talk) diff --git a/native/cmd/lich-native/pulls_edit.go b/native/cmd/lich-native/pulls_edit.go index 570afb6b7..c12518f31 100644 --- a/native/cmd/lich-native/pulls_edit.go +++ b/native/cmd/lich-native/pulls_edit.go @@ -8,8 +8,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -59,7 +59,7 @@ func (h *heldEdit) release() { h.on = false } // url as read. The read catches up once it equals the value sent, or once one // has come back from a fetch begun after a stale that followed the send: a // value GitHub normalised never equals what was sent, and must not stay held. -func (h *heldEdit) over(url, current string, read pullsRead[*lichclient.PRDetail], staledAt time.Time) string { +func (h *heldEdit) over(url, current string, read pullsRead[*backend.PRDetail], staledAt time.Time) string { if !h.on || h.url != url { return current } @@ -73,21 +73,21 @@ func (h *heldEdit) over(url, current string, read pullsRead[*lichclient.PRDetail // shown is d with the title and description being sent in place of the ones // read. -func (s *editState) shown(d lichclient.PRDetail, read pullsRead[*lichclient.PRDetail], staledAt time.Time) lichclient.PRDetail { +func (s *editState) shown(d backend.PRDetail, read pullsRead[*backend.PRDetail], staledAt time.Time) backend.PRDetail { d.Title = s.heldTitle.over(d.URL, d.Title, read, staledAt) d.Body = s.heldBody.over(d.URL, d.Body, read, staledAt) return d } // titleEditing reports whether the title field is open on d. -func (s *editState) titleEditing(d lichclient.PRDetail) bool { +func (s *editState) titleEditing(d backend.PRDetail) bool { return s.title.Active() && s.titleURL == d.URL } // pullTitle is the number and the title, or the field renaming it, with the // pencil that opens the field right after the title's last word. Enter or // leaving the field saves, Escape cancels, as a session card's rename. -func (v *pullView) pullTitle(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction) D { +func (v *pullView) pullTitle(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { s := &v.edit if s.titleOpen.click.Clicked(gtx) { s.titleURL = d.URL @@ -123,7 +123,7 @@ func (v *pullView) pullTitle(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.P // descriptionHeading is the Description heading, with the Edit that turns the // description into a field while it is not one already. -func (v *pullView) descriptionHeading(gtx C, th *ui.Theme, d lichclient.PRDetail) D { +func (v *pullView) descriptionHeading(gtx C, th *ui.Theme, d backend.PRDetail) D { s := &v.edit // Cancel is the field's, drawn under the heading: taken here, the // heading and the field both read it. @@ -152,7 +152,7 @@ func (v *pullView) descriptionHeading(gtx C, th *ui.Theme, d lichclient.PRDetail // descriptionField is the description being edited, raw Markdown, saved with // Save or Ctrl+Enter. It stays open, saying so, until GitHub took it. -func (v *pullView) descriptionField(gtx C, th *ui.Theme, d lichclient.PRDetail, act *pullsAction) D { +func (v *pullView) descriptionField(gtx C, th *ui.Theme, d backend.PRDetail, act *pullsAction) D { s := &v.edit busy := v.busyNow() == "body" if (s.bodySave.Clicked(gtx) || ctrlEnter(gtx, &s.body)) && !busy { @@ -189,7 +189,7 @@ func (u *window) applyEdit(path string, number int, act pullsAction) { if act.title != "" { title := act.title go u.prWrite("title", func(err error) string { - return i18n.T("pulls.pullRequestView.renameFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + return i18n.T("pulls.pullRequestView.renameFailed", i18n.Args{"error": backend.ErrorText(err)}) }, func() error { if err := u.client.EditPullRequestTitle(u.ctx, path, number, title); err != nil { v.onFrame(v.edit.heldTitle.release) @@ -201,7 +201,7 @@ func (u *window) applyEdit(path string, number int, act pullsAction) { if act.body != nil { body := *act.body go u.prWrite("body", func(err error) string { - return i18n.T("pulls.pullsOverview.editFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + return i18n.T("pulls.pullsOverview.editFailed", i18n.Args{"error": backend.ErrorText(err)}) }, func() error { if err := u.client.EditPullRequestBody(u.ctx, path, number, body); err != nil { v.onFrame(v.edit.heldBody.release) @@ -214,7 +214,7 @@ func (u *window) applyEdit(path string, number int, act pullsAction) { } // openForShot opens the edit -pulls-edit names (title, description) on d. -func (s *editState) openForShot(what string, d lichclient.PRDetail) { +func (s *editState) openForShot(what string, d backend.PRDetail) { switch what { case "title": s.titleURL = d.URL diff --git a/native/cmd/lich-native/pulls_edit_test.go b/native/cmd/lich-native/pulls_edit_test.go index 38e82a211..8f666e12b 100644 --- a/native/cmd/lich-native/pulls_edit_test.go +++ b/native/cmd/lich-native/pulls_edit_test.go @@ -4,26 +4,26 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestHeldEditShowsTheSentValueUntilAReadCatchesUp(t *testing.T) { sent := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) const url = "https://github.com/o/r/pull/7" - before := pullsRead[*lichclient.PRDetail]{loaded: true, at: sent.Add(-time.Minute)} + before := pullsRead[*backend.PRDetail]{loaded: true, at: sent.Add(-time.Minute)} cases := []struct { name string current string - read pullsRead[*lichclient.PRDetail] + read pullsRead[*backend.PRDetail] staledAt time.Time want string }{ {"the read from before the send", "Old", before, sent.Add(-time.Minute), "New"}, {"a read in flight when the write's stale landed", "Old", - pullsRead[*lichclient.PRDetail]{loaded: true, stale: true, at: sent.Add(2 * time.Second)}, sent.Add(time.Second), "New"}, + pullsRead[*backend.PRDetail]{loaded: true, stale: true, at: sent.Add(2 * time.Second)}, sent.Add(time.Second), "New"}, {"a read that equals what was sent", "New", before, sent.Add(-time.Minute), "New"}, {"a read fetched after the write's stale, normalised by GitHub", "New\r\n", - pullsRead[*lichclient.PRDetail]{loaded: true, at: sent.Add(2 * time.Second)}, sent.Add(time.Second), "New\r\n"}, + pullsRead[*backend.PRDetail]{loaded: true, at: sent.Add(2 * time.Second)}, sent.Add(time.Second), "New\r\n"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -39,7 +39,7 @@ func TestHeldEditShowsTheSentValueUntilAReadCatchesUp(t *testing.T) { func TestHeldEditStaysWithItsPullRequest(t *testing.T) { var h heldEdit h.hold("https://github.com/o/r/pull/7", "New", time.Now()) - if got := h.over("https://github.com/o/r/pull/8", "Other", pullsRead[*lichclient.PRDetail]{}, time.Time{}); got != "Other" { + if got := h.over("https://github.com/o/r/pull/8", "Other", pullsRead[*backend.PRDetail]{}, time.Time{}); got != "Other" { t.Errorf("another pull request shows %q, want its own title", got) } } @@ -48,8 +48,8 @@ func TestHeldEditReleasedOnceCaughtUp(t *testing.T) { var h heldEdit sent := time.Now() h.hold("u", "New", sent) - h.over("u", "New", pullsRead[*lichclient.PRDetail]{}, time.Time{}) - if got := h.over("u", "Renamed on GitHub", pullsRead[*lichclient.PRDetail]{}, time.Time{}); got != "Renamed on GitHub" { + h.over("u", "New", pullsRead[*backend.PRDetail]{}, time.Time{}) + if got := h.over("u", "Renamed on GitHub", pullsRead[*backend.PRDetail]{}, time.Time{}); got != "Renamed on GitHub" { t.Errorf("after catching up shows %q, want the read", got) } } diff --git a/native/cmd/lich-native/pulls_files.go b/native/cmd/lich-native/pulls_files.go index 990836924..68274d754 100644 --- a/native/cmd/lich-native/pulls_files.go +++ b/native/cmd/lich-native/pulls_files.go @@ -19,8 +19,8 @@ import ( "github.com/lichdotdev/godemirror" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -42,7 +42,7 @@ func parseTreeWidth(raw string) float64 { return min(treeMaxRem, max(treeMinRem, rem)) } -func loadTreeWidth(ctx context.Context, c *lichclient.Client) (float64, error) { +func loadTreeWidth(ctx context.Context, c *backend.Client) (float64, error) { raw, err := c.GetSetting(ctx, treeWidthKey, "") return parseTreeWidth(raw), err } @@ -113,7 +113,7 @@ func (p *reviewPanel) takeTicks(gtx C) { // filesTab is PullsFiles: the toolbar over the PR's diff, one card per file // with the comment batch at the foot, the dock's Review cards read-only. -func (v *pullView) filesTab(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction) D { +func (v *pullView) filesTab(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { files := v.files if files == nil { return D{} @@ -285,7 +285,7 @@ func (v *pullView) filesToolbar(gtx C, th *ui.Theme) D { type inlineFrame struct { th *ui.Theme p *pullsScreen - d lichclient.PRDetail + d backend.PRDetail act *pullsAction now time.Time } @@ -347,7 +347,7 @@ func (v *pullView) inlineThread(gtx C, id string) D { } // inlinePending is a comment waiting for the review to be submitted. -func (v *pullView) inlinePending(gtx C, c lichclient.ReviewComment) D { +func (v *pullView) inlinePending(gtx C, c backend.ReviewComment) D { th := v.inline.th return composerSlot.Layout(gtx, func(gtx C) D { return layout.Background{}.Layout(gtx, diff --git a/native/cmd/lich-native/pulls_i18n_test.go b/native/cmd/lich-native/pulls_i18n_test.go index 787a97e29..6c9d5d05a 100644 --- a/native/cmd/lich-native/pulls_i18n_test.go +++ b/native/cmd/lich-native/pulls_i18n_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -28,7 +28,7 @@ func TestPatternClauseNamesEveryOperatorAndNegation(t *testing.T) { {"never_seen", false, `message must match "x"`}, } for _, c := range cases { - p := lichclient.CommitPattern{Target: "message", Operator: c.operator, Pattern: "x", Negate: c.negate} + p := backend.CommitPattern{Target: "message", Operator: c.operator, Pattern: "x", Negate: c.negate} if got := patternClause(p); got != c.want { t.Errorf("%s negate=%v: %q, want %q", c.operator, c.negate, got, c.want) } @@ -47,7 +47,7 @@ func TestPatternTargetKeepsAWordTheBackendAddsLater(t *testing.T) { func TestMergeGateSpeaksTheInterfaceLanguage(t *testing.T) { i18n.SetCurrent(i18n.PtBR) defer i18n.SetCurrent(i18n.EN) - rules := &lichclient.BranchRules{CommitPatterns: []lichclient.CommitPattern{{Target: "author email", Operator: "ends_with", Pattern: "@x.dev", Negate: true}}} + rules := &backend.BranchRules{CommitPatterns: []backend.CommitPattern{{Target: "author email", Operator: "ends_with", Pattern: "@x.dev", Negate: true}}} blocked := detail("OPEN", "MERGEABLE", "BLOCKED", "APPROVED") want := `Um ruleset governa main: em todo commit, o e-mail do autor não deve terminar com "@x.dev".` if got := mergeHint(blocked, rules); got != want { @@ -76,8 +76,8 @@ func TestMergeHintCountsTheRedChecksInTheInterfaceLanguage(t *testing.T) { } func TestChecksStatPicksThePluralOfTheInterfaceLanguage(t *testing.T) { - one := lichclient.ChecksRollup{Total: 1, Passed: 1} - many := lichclient.ChecksRollup{Total: 4, Passed: 4} + one := backend.ChecksRollup{Total: 1, Passed: 1} + many := backend.ChecksRollup{Total: 4, Passed: 4} if _, text, _, _ := checksStat(testTheme(), one); text != "1 check passed" { t.Errorf("one = %q", text) } diff --git a/native/cmd/lich-native/pulls_list.go b/native/cmd/lich-native/pulls_list.go index 1371947a6..fc23b4ecd 100644 --- a/native/cmd/lich-native/pulls_list.go +++ b/native/cmd/lich-native/pulls_list.go @@ -17,8 +17,8 @@ import ( "gioui.org/widget" "gioui.org/widget/material" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -45,7 +45,7 @@ func (s pullsState) none() string { // listLayout is PullsList: the filter field and the counted quick filters, // the state menu, then the rows. -func (p *pullsScreen) listLayout(gtx C, th *ui.Theme, list pullsRead[[]lichclient.PRSummary]) D { +func (p *pullsScreen) listLayout(gtx C, th *ui.Theme, list pullsRead[[]backend.PRSummary]) D { text := p.query.Text() counts := filterCounts(list.val, text) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -131,7 +131,7 @@ func (p *pullsScreen) stateRow(gtx C, th *ui.Theme) D { }) } -func (p *pullsScreen) rowList(gtx C, th *ui.Theme, list pullsRead[[]lichclient.PRSummary], rows []lichclient.PRSummary) D { +func (p *pullsScreen) rowList(gtx C, th *ui.Theme, list pullsRead[[]backend.PRSummary], rows []backend.PRSummary) D { switch { case !list.loaded: return listSkeleton(gtx, th) @@ -167,7 +167,7 @@ func (p *pullsScreen) rowScroll(gtx C, th *ui.Theme) material.ListStyle { // row is PullRow: "#N title" over two lines at most beside its checks, then // who, when, the facts worth a glance and the checkout it sits in. -func (p *pullsScreen) row(gtx C, th *ui.Theme, pr lichclient.PRSummary, now time.Time) D { +func (p *pullsScreen) row(gtx C, th *ui.Theme, pr backend.PRSummary, now time.Time) D { click := p.rowClicks[pr.Number] if click == nil { click = new(widget.Clickable) @@ -205,7 +205,7 @@ func (p *pullsScreen) row(gtx C, th *ui.Theme, pr lichclient.PRSummary, now time // first line. var chipFirstLineTop = ui.Space(0.5) -func rowTitle(gtx C, th *ui.Theme, pr lichclient.PRSummary) D { +func rowTitle(gtx C, th *ui.Theme, pr backend.PRSummary) D { return layout.Flex{}.Layout(gtx, layout.Rigid(th.Text(ui.TextSM, fmt.Sprintf("#%d", pr.Number)).Mono().Muted().Layout), ui.Gap(1), @@ -227,7 +227,7 @@ func rowTitle(gtx C, th *ui.Theme, pr lichclient.PRSummary) D { // checkoutName is the folder a PR's branch is checked out in, the name its // block in the sidebar carries; "" when it is checked out nowhere. -func checkoutName(w lichclient.Worktree) string { +func checkoutName(w backend.Worktree) string { if w.Path == "" { return "" } @@ -238,7 +238,7 @@ func checkoutName(w lichclient.Worktree) string { // shows, REVIEW_REQUIRED being most rows' resting state. The facts take the // room first: the author truncates once they don't fit, and the checkout at // the far end shows only where it fits whole. -func rowMeta(gtx C, th *ui.Theme, pr lichclient.PRSummary, checkout string, now time.Time) D { +func rowMeta(gtx C, th *ui.Theme, pr backend.PRSummary, checkout string, now time.Time) D { facts := []layout.FlexChild{ layout.Flexed(1, func(gtx C) D { gtx.Constraints.Min.X = 0 @@ -301,7 +301,7 @@ func draftBadge(gtx C, th *ui.Theme) D { // waitsOnReviewer is a CHANGES_REQUESTED verdict whose every thread is // resolved: GitHub keeps the verdict until the reviewer moves. -func waitsOnReviewer(pr lichclient.PRSummary) bool { +func waitsOnReviewer(pr backend.PRSummary) bool { return pr.ReviewDecision == "CHANGES_REQUESTED" && pr.Threads.Total > 0 && pr.Threads.Open == 0 } @@ -322,7 +322,7 @@ func wholeOrNothing(gtx C, w layout.Widget) D { // checksChip is a row's checks at a glance: how many have finished while // any run, how many failed once one has, all of them once they pass. -func checksChip(gtx C, th *ui.Theme, c lichclient.ChecksRollup) D { +func checksChip(gtx C, th *ui.Theme, c backend.ChecksRollup) D { ic, fg, text := icPullCheck, th.TonePass, fmt.Sprintf("%d/%d", c.Passed, c.Total) switch verdictOf(c) { case verdictFailed: diff --git a/native/cmd/lich-native/pulls_list_test.go b/native/cmd/lich-native/pulls_list_test.go index 8201c51fc..832f35f66 100644 --- a/native/cmd/lich-native/pulls_list_test.go +++ b/native/cmd/lich-native/pulls_list_test.go @@ -13,9 +13,9 @@ import ( "gioui.org/text" "gioui.org/unit" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/fonts" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -66,8 +66,8 @@ func inkWidth(t *testing.T, w layout.Widget, fg color.NRGBA) int { return ink.Dx() } -func requested(author string, threads lichclient.ThreadTally) lichclient.PRSummary { - return lichclient.PRSummary{Author: author, ReviewDecision: "CHANGES_REQUESTED", Threads: threads, +func requested(author string, threads backend.ThreadTally) backend.PRSummary { + return backend.PRSummary{Author: author, ReviewDecision: "CHANGES_REQUESTED", Threads: threads, UpdatedAt: rowMetaNow.Add(-59 * time.Minute).Format(time.RFC3339)} } @@ -76,7 +76,7 @@ func requested(author string, threads lichclient.ThreadTally) lichclient.PRSumma func TestRowMetaKeepsTheVerdictWhole(t *testing.T) { th := rowMetaTheme(t) whole := inkWidth(t, th.Text(ui.TextXS, i18n.T("pulls.pullsStats.changesRequested")).In(th.Destructive).Layout, th.Destructive) - pr := requested("joao-vitorino-dos-santos_snk", lichclient.ThreadTally{Open: 1, Total: 1}) + pr := requested("joao-vitorino-dos-santos_snk", backend.ThreadTally{Open: 1, Total: 1}) got := inkWidth(t, func(gtx C) D { return rowMeta(gtx, th, pr, "rapid-quartz", rowMetaNow) }, th.Destructive) if got < whole { t.Errorf("verdict drew %dpx of its %dpx", got, whole) @@ -89,12 +89,12 @@ func TestRowMetaVerdictFollowsTheThreads(t *testing.T) { th := rowMetaTheme(t) cases := []struct { name string - threads lichclient.ThreadTally + threads backend.ThreadTally red bool }{ - {"an open thread is the author's move", lichclient.ThreadTally{Open: 1, Total: 3}, true}, - {"no threads leaves only the verdict", lichclient.ThreadTally{}, true}, - {"every thread resolved is the reviewer's move", lichclient.ThreadTally{Open: 0, Total: 3}, false}, + {"an open thread is the author's move", backend.ThreadTally{Open: 1, Total: 3}, true}, + {"no threads leaves only the verdict", backend.ThreadTally{}, true}, + {"every thread resolved is the reviewer's move", backend.ThreadTally{Open: 0, Total: 3}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -124,7 +124,7 @@ func TestWholeOrNothingDropsWhatWouldBeCut(t *testing.T) { // the pull request's state, drawn muted so the two never read as one. func TestRowMetaDraftLeavesAmberToTheVerdict(t *testing.T) { th := rowMetaTheme(t) - pr := lichclient.PRSummary{Author: "ana", IsDraft: true, UpdatedAt: rowMetaNow.Add(-5 * time.Hour).Format(time.RFC3339)} + pr := backend.PRSummary{Author: "ana", IsDraft: true, UpdatedAt: rowMetaNow.Add(-5 * time.Hour).Format(time.RFC3339)} meta := func(gtx C) D { return rowMeta(gtx, th, pr, "", rowMetaNow) } if amber := inkWidth(t, meta, th.ToneWait); amber > 0 { t.Errorf("a draft with no verdict drew %dpx of amber", amber) diff --git a/native/cmd/lich-native/pulls_logic.go b/native/cmd/lich-native/pulls_logic.go index a29dc307e..05336d1b5 100644 --- a/native/cmd/lich-native/pulls_logic.go +++ b/native/cmd/lich-native/pulls_logic.go @@ -6,8 +6,8 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // pullsState is which pull requests gh is asked for. @@ -53,7 +53,7 @@ const ( verdictFailed ) -func verdictOf(c lichclient.ChecksRollup) checkVerdict { +func verdictOf(c backend.ChecksRollup) checkVerdict { switch { case c.Total == 0: return verdictNone @@ -65,7 +65,7 @@ func verdictOf(c lichclient.ChecksRollup) checkVerdict { return verdictPassed } -func (f pullsFilter) matches(pr lichclient.PRSummary) bool { +func (f pullsFilter) matches(pr backend.PRSummary) bool { switch f { case filterReady: return !pr.IsDraft @@ -79,7 +79,7 @@ func (f pullsFilter) matches(pr lichclient.PRSummary) bool { // matchesText matches text against the number with or without its "#", the // title, the author and the branch. -func matchesText(pr lichclient.PRSummary, text string) bool { +func matchesText(pr backend.PRSummary, text string) bool { needle := strings.ToLower(strings.TrimSpace(text)) if needle == "" { return true @@ -94,19 +94,19 @@ func matchesText(pr lichclient.PRSummary, text string) bool { } // filterPulls is the rows the filter and the text keep, newest update first. -func filterPulls(list []lichclient.PRSummary, f pullsFilter, text string) []lichclient.PRSummary { - var rows []lichclient.PRSummary +func filterPulls(list []backend.PRSummary, f pullsFilter, text string) []backend.PRSummary { + var rows []backend.PRSummary for _, pr := range list { if f.matches(pr) && matchesText(pr, text) { rows = append(rows, pr) } } - slices.SortStableFunc(rows, func(a, b lichclient.PRSummary) int { return strings.Compare(b.UpdatedAt, a.UpdatedAt) }) + slices.SortStableFunc(rows, func(a, b backend.PRSummary) int { return strings.Compare(b.UpdatedAt, a.UpdatedAt) }) return rows } // filterCounts is how many rows each quick filter would keep under text. -func filterCounts(list []lichclient.PRSummary, text string) [pullsFilterCount]int { +func filterCounts(list []backend.PRSummary, text string) [pullsFilterCount]int { var counts [pullsFilterCount]int for _, pr := range list { if !matchesText(pr, text) { @@ -144,7 +144,7 @@ func mergeMethodLabel(method string) string { // allowedMergeMethods is what the base's rules name, all three // when they name none. -func allowedMergeMethods(rules *lichclient.BranchRules) []string { +func allowedMergeMethods(rules *backend.BranchRules) []string { var allowed []string for _, m := range everyMergeMethod { if rules != nil && slices.Contains(rules.AllowedMergeMethods, m) { @@ -157,14 +157,14 @@ func allowedMergeMethods(rules *lichclient.BranchRules) []string { return allowed } -func conflictsWithBase(d lichclient.PRDetail) bool { +func conflictsWithBase(d backend.PRDetail) bool { return d.MergeStateStatus == "DIRTY" || d.Mergeable == "CONFLICTING" } // mergeBlockedReason is why the merge cannot go, "" when GitHub // would take it (or a BLOCKED it gives no reason for, which only the merge // attempt can explain). -func mergeBlockedReason(d lichclient.PRDetail) string { +func mergeBlockedReason(d backend.PRDetail) string { switch { case d.State == "MERGED": return i18n.T("pulls.mergeGate.merged") @@ -188,7 +188,7 @@ func mergeBlockedReason(d lichclient.PRDetail) string { // canAdminOverride says whether an administrator may merge past BLOCKED or // BEHIND, never past a conflict. -func canAdminOverride(d lichclient.PRDetail, rules *lichclient.BranchRules) bool { +func canAdminOverride(d backend.PRDetail, rules *backend.BranchRules) bool { if rules == nil || !rules.ViewerCanBypass || d.State != "OPEN" || d.IsDraft || d.Mergeable == "CONFLICTING" { return false } @@ -198,7 +198,7 @@ func canAdminOverride(d lichclient.PRDetail, rules *lichclient.BranchRules) bool // patternClause is " must " in the interface // language. An operator this build has never seen reads as a match, the // pattern beside it still saying what is asked. -func patternClause(p lichclient.CommitPattern) string { +func patternClause(p backend.CommitPattern) string { args := i18n.Args{"target": patternTarget(p.Target), "pattern": strconv.Quote(p.Pattern)} switch { case p.Operator == "starts_with" && p.Negate: @@ -235,7 +235,7 @@ func patternTarget(target string) string { // mergeRuleNote is, on BLOCKED, the commit patterns the base's // rules hold every commit to, the one rule nothing else on the PR shows. -func mergeRuleNote(d lichclient.PRDetail, rules *lichclient.BranchRules) string { +func mergeRuleNote(d backend.PRDetail, rules *backend.BranchRules) string { if d.MergeStateStatus != "BLOCKED" || rules == nil || len(rules.CommitPatterns) == 0 { return "" } @@ -248,7 +248,7 @@ func mergeRuleNote(d lichclient.PRDetail, rules *lichclient.BranchRules) string // mergeHint is what the Merge button's tooltip says: the block, else the rule // note, else the red checks GitHub would merge over. -func mergeHint(d lichclient.PRDetail, rules *lichclient.BranchRules) string { +func mergeHint(d backend.PRDetail, rules *backend.BranchRules) string { if r := mergeBlockedReason(d); r != "" { return r } @@ -268,7 +268,7 @@ const handoffNamedChecks = 8 // pullHandoff finds what is wrong with an open PR, conflicts // first, as a button label and the prompt that hands it over; ok false when // nothing is. -func pullHandoff(d lichclient.PRDetail) (label, prompt string, ok bool) { +func pullHandoff(d backend.PRDetail) (label, prompt string, ok bool) { if d.State != "OPEN" { return "", "", false } @@ -282,8 +282,8 @@ func pullHandoff(d lichclient.PRDetail) (label, prompt string, ok bool) { return "", "", false } -func checksPrompt(d lichclient.PRDetail) string { - var failed []lichclient.CheckItem +func checksPrompt(d backend.PRDetail) string { + var failed []backend.CheckItem for _, c := range d.CheckRuns { if c.State == "failed" { failed = append(failed, c) @@ -357,7 +357,7 @@ func durationUnit(unit string, value int) string { } // commitMeta is the commit's second line: who and when. -func commitMeta(c lichclient.PRCommit, now time.Time) string { +func commitMeta(c backend.PRCommit, now time.Time) string { who := c.Login if who == "" { who = c.Name diff --git a/native/cmd/lich-native/pulls_logic_test.go b/native/cmd/lich-native/pulls_logic_test.go index 0ceb60a9f..3ce320fc1 100644 --- a/native/cmd/lich-native/pulls_logic_test.go +++ b/native/cmd/lich-native/pulls_logic_test.go @@ -6,18 +6,18 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) -func summary(n int, title string, draft bool, failed int, updated string) lichclient.PRSummary { - return lichclient.PRSummary{ +func summary(n int, title string, draft bool, failed int, updated string) backend.PRSummary { + return backend.PRSummary{ Number: n, Title: title, Author: "octo", HeadRefName: "feat/" + title, IsDraft: draft, UpdatedAt: updated, - Checks: lichclient.ChecksRollup{Failed: failed, Total: max(failed, 1)}, + Checks: backend.ChecksRollup{Failed: failed, Total: max(failed, 1)}, } } -func numbers(rows []lichclient.PRSummary) []int { +func numbers(rows []backend.PRSummary) []int { out := make([]int, len(rows)) for i, r := range rows { out[i] = r.Number @@ -26,7 +26,7 @@ func numbers(rows []lichclient.PRSummary) []int { } func TestFilterPullsKeepsWhatTheFilterAndTextAgreeOnNewestFirst(t *testing.T) { - list := []lichclient.PRSummary{ + list := []backend.PRSummary{ summary(1, "old", false, 0, "2026-01-01T00:00:00Z"), summary(2, "draft", true, 0, "2026-03-01T00:00:00Z"), summary(3, "red", false, 2, "2026-02-01T00:00:00Z"), @@ -59,15 +59,15 @@ func TestFilterPullsKeepsWhatTheFilterAndTextAgreeOnNewestFirst(t *testing.T) { } } -func detail(state, mergeable, status, review string) lichclient.PRDetail { - return lichclient.PRDetail{State: state, Mergeable: mergeable, MergeStateStatus: status, ReviewDecision: review, BaseRefName: "main"} +func detail(state, mergeable, status, review string) backend.PRDetail { + return backend.PRDetail{State: state, Mergeable: mergeable, MergeStateStatus: status, ReviewDecision: review, BaseRefName: "main"} } func TestMergeBlockedReasonFollowsTheWebOrder(t *testing.T) { draft := detail("OPEN", "MERGEABLE", "DRAFT", "") draft.IsDraft = true cases := map[string]struct { - d lichclient.PRDetail + d backend.PRDetail want string }{ "merged": {detail("MERGED", "", "", ""), "Pull request is merged"}, @@ -89,7 +89,7 @@ func TestMergeBlockedReasonFollowsTheWebOrder(t *testing.T) { } func TestAdminOverrideNeedsThePrivilegeAndNoConflict(t *testing.T) { - admin := &lichclient.BranchRules{ViewerCanBypass: true} + admin := &backend.BranchRules{ViewerCanBypass: true} if !canAdminOverride(detail("OPEN", "MERGEABLE", "BLOCKED", ""), admin) { t.Error("an administrator should merge past BLOCKED") } @@ -102,7 +102,7 @@ func TestAdminOverrideNeedsThePrivilegeAndNoConflict(t *testing.T) { if canAdminOverride(detail("OPEN", "MERGEABLE", "CLEAN", ""), admin) { t.Error("a clean PR has nothing to bypass") } - if canAdminOverride(detail("OPEN", "MERGEABLE", "BLOCKED", ""), &lichclient.BranchRules{}) { + if canAdminOverride(detail("OPEN", "MERGEABLE", "BLOCKED", ""), &backend.BranchRules{}) { t.Error("no privilege, no bypass") } if canAdminOverride(detail("OPEN", "MERGEABLE", "BLOCKED", ""), nil) { @@ -114,14 +114,14 @@ func TestAllowedMergeMethods(t *testing.T) { if got := allowedMergeMethods(nil); !slices.Equal(got, everyMergeMethod) { t.Errorf("no rules = %v, want every method", got) } - r := &lichclient.BranchRules{AllowedMergeMethods: []string{"rebase", "squash"}} + r := &backend.BranchRules{AllowedMergeMethods: []string{"rebase", "squash"}} if got := allowedMergeMethods(r); !slices.Equal(got, []string{"squash", "rebase"}) { t.Errorf("named = %v, want [squash rebase] in the menu's order", got) } } func TestMergeHintFallsFromBlockToRuleToChecks(t *testing.T) { - rules := &lichclient.BranchRules{CommitPatterns: []lichclient.CommitPattern{{Target: "message", Operator: "starts_with", Pattern: "feat"}}} + rules := &backend.BranchRules{CommitPatterns: []backend.CommitPattern{{Target: "message", Operator: "starts_with", Pattern: "feat"}}} blocked := detail("OPEN", "MERGEABLE", "BLOCKED", "APPROVED") if got := mergeHint(blocked, rules); got != `A ruleset governs main: every commit message must start with "feat".` { t.Errorf("rule note = %q", got) @@ -145,9 +145,9 @@ func TestPullHandoffConflictsFirstThenChecks(t *testing.T) { t.Fatalf("conflict handoff = %q %q %v", label, prompt, ok) } d.Mergeable, d.MergeStateStatus = "MERGEABLE", "UNSTABLE" - runs := make([]lichclient.CheckItem, handoffNamedChecks+2) + runs := make([]backend.CheckItem, handoffNamedChecks+2) for i := range runs { - runs[i] = lichclient.CheckItem{Name: "job", State: "failed", URL: "https://ci"} + runs[i] = backend.CheckItem{Name: "job", State: "failed", URL: "https://ci"} } d.CheckRuns = runs label, prompt, _ = pullHandoff(d) @@ -188,7 +188,7 @@ func TestCheckDuration(t *testing.T) { } func TestMergeEditStartsFromGitHubsOwnMessage(t *testing.T) { - d := lichclient.PRDetail{Number: 7, Title: "feat: x", Body: "why", HeadRefName: "feat/x"} + d := backend.PRDetail{Number: 7, Title: "feat: x", Body: "why", HeadRefName: "feat/x"} if got := mergeEditFor("squash", d); got.subject != "feat: x (#7)" || got.body != "why" || got.title != "Squash and merge" { t.Errorf("squash edit = %+v", got) } diff --git a/native/cmd/lich-native/pulls_merge_dialog.go b/native/cmd/lich-native/pulls_merge_dialog.go index 7d8c46e40..9d0fcab19 100644 --- a/native/cmd/lich-native/pulls_merge_dialog.go +++ b/native/cmd/lich-native/pulls_merge_dialog.go @@ -9,8 +9,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -34,7 +34,7 @@ type mergeEdit struct { method, title, subject, body string } -func mergeEditFor(method string, d lichclient.PRDetail) mergeEdit { +func mergeEditFor(method string, d backend.PRDetail) mergeEdit { if method == "squash" { return mergeEdit{method: method, title: i18n.T("pulls.mergeMessageDialog.squashAndMerge"), subject: fmt.Sprintf(mergeEditSquashSubj, d.Title, d.Number), body: d.Body} } @@ -57,7 +57,7 @@ type mergeDialog struct { } // show opens the dialog on method's message for d. -func (m *mergeDialog) show(method string, admin bool, d lichclient.PRDetail) { +func (m *mergeDialog) show(method string, admin bool, d backend.PRDetail) { m.open, m.admin, m.focus = true, admin, true m.edit = mergeEditFor(method, d) m.subject.SingleLine, m.subject.Submit = false, true @@ -67,7 +67,7 @@ func (m *mergeDialog) show(method string, admin bool, d lichclient.PRDetail) { // editEntries are the Merge menu's "edit message…" items, below a separator, // for the allowed methods that take a message. -func (v *pullView) editEntries(methods []string, bypass bool, d lichclient.PRDetail) []ui.MenuEntry { +func (v *pullView) editEntries(methods []string, bypass bool, d backend.PRDetail) []ui.MenuEntry { var out []ui.MenuEntry for _, m := range methods { if m == "rebase" { @@ -94,7 +94,7 @@ func (u *window) layoutMergeDialog(gtx C, th *ui.Theme) { if m.cancel.Clicked(gtx) { m.open = false } - var d *lichclient.PRDetail + var d *backend.PRDetail if m.open && p.open && p.selected != 0 { d = p.store.detail(p.project.Path, p.selected).val } diff --git a/native/cmd/lich-native/pulls_panels.go b/native/cmd/lich-native/pulls_panels.go index 5cd27fe88..32257418a 100644 --- a/native/cmd/lich-native/pulls_panels.go +++ b/native/cmd/lich-native/pulls_panels.go @@ -9,14 +9,14 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) // actions is the header's buttons, left to right: the session, the hand-off, // Approve, Merge, Refresh and Open. -func (v *pullView) actions(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction) D { +func (v *pullView) actions(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { busy := v.busyNow() handLabel, handPrompt, handOK := pullHandoff(d) blocked := "" @@ -66,7 +66,7 @@ func (v *pullView) actions(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRD // sessionButton is Open in Session, or Go to session when a session already // sits in the branch's checkout. -func (v *pullView) sessionButton(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, busy, blocked string) D { +func (v *pullView) sessionButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, busy, blocked string) D { label := i18n.T("pulls.pulls.openInSession") if id, _ := p.reviewSession(d); id != "" { label = i18n.T("pulls.pulls.goToSession") @@ -81,7 +81,7 @@ func (v *pullView) sessionButton(gtx C, th *ui.Theme, p *pullsScreen, d lichclie // mergeReasonForReview is why a review cannot be left: only a pull request // that is over refuses one, so a draft or a conflict still takes it. -func mergeReasonForReview(d lichclient.PRDetail) string { +func mergeReasonForReview(d backend.PRDetail) string { switch d.State { case "OPEN": return "" @@ -101,7 +101,7 @@ func (v *pullView) withTip(gtx C, th *ui.Theme, p *pullsScreen, key, text string // mergeButton is the Merge menu: the methods the base's rules allow, every // one a bypass or none, enabled past a block only where a bypass is offered. -func (v *pullView) mergeButton(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, busy string, act *pullsAction) D { +func (v *pullView) mergeButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, busy string, act *pullsAction) D { rules := p.store.branchRules(p.project.Path, d.BaseRefName) bypass := canAdminOverride(d, rules) blocked := mergeBlockedReason(d) != "" && !bypass @@ -139,7 +139,7 @@ const ( // conflictsStat is the "Conflicts with" stat, face: once the files are known // it opens a panel naming them, and until then its tooltip says why not. -func (v *pullView) conflictsStat(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, face layout.Widget) D { +func (v *pullView) conflictsStat(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, face layout.Widget) D { read := p.store.conflictFiles(p.project.Path, &d) if hint := conflictsHint(read); hint != "" { return v.withTip(gtx, th, p, "conflicts", hint, face) @@ -201,7 +201,7 @@ func conflictRow(gtx C, th *ui.Theme, file string) D { }) } -func (v *pullView) panelLayout(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction) D { +func (v *pullView) panelLayout(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { var rows []layout.Widget switch v.openTab(d) { case tabFiles: @@ -222,7 +222,7 @@ func (v *pullView) panelLayout(gtx C, th *ui.Theme, p *pullsScreen, d lichclient // overviewRows is PullsOverview: who opened it, who reviews it, and its // description. -func (v *pullView) overviewRows(th *ui.Theme, d lichclient.PRDetail, act *pullsAction) []layout.Widget { +func (v *pullView) overviewRows(th *ui.Theme, d backend.PRDetail, act *pullsAction) []layout.Widget { pad := func(top float32, w layout.Widget) layout.Widget { return func(gtx C) D { return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(top)}.Layout(gtx, w) @@ -267,7 +267,7 @@ func reviewVerdict(state string) (text string, ok bool) { } // reviewerChips is the reviewers row: each login and what they said. -func reviewerChips(gtx C, th *ui.Theme, reviewers []lichclient.PRReviewer) D { +func reviewerChips(gtx C, th *ui.Theme, reviewers []backend.PRReviewer) D { if len(reviewers) == 0 { return th.Text(ui.TextSM, i18n.T("pulls.pullsOverview.nobodyYet")).Muted().Layout(gtx) } @@ -302,7 +302,7 @@ func reviewerChips(gtx C, th *ui.Theme, reviewers []lichclient.PRReviewer) D { } // commitRows is PullsCommits: each commit, its body behind a click. -func (v *pullView) commitRows(gtx C, th *ui.Theme, d lichclient.PRDetail) []layout.Widget { +func (v *pullView) commitRows(gtx C, th *ui.Theme, d backend.PRDetail) []layout.Widget { if len(d.Commits) == 0 { return []layout.Widget{func(gtx C) D { return panelNotice(gtx, th, i18n.T("pulls.pullsCommits.none")) }} } @@ -334,7 +334,7 @@ func (v *pullView) commitRows(gtx C, th *ui.Theme, d lichclient.PRDetail) []layo // commitRow is a commit's headline, who and when, the chevron when it has a // body to open, and its short oid. -func commitRow(gtx C, th *ui.Theme, c lichclient.PRCommit, click *widget.Clickable, hasBody, open bool, now time.Time) D { +func commitRow(gtx C, th *ui.Theme, c backend.PRCommit, click *widget.Clickable, hasBody, open bool, now time.Time) D { return panelRow(gtx, th, click, hasBody, func(gtx C, th *ui.Theme) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx C) D { @@ -359,7 +359,7 @@ func commitRow(gtx C, th *ui.Theme, c lichclient.PRCommit, click *widget.Clickab } // checkRows is PullsChecks: each check, its run opened in the browser. -func (v *pullView) checkRows(gtx C, th *ui.Theme, d lichclient.PRDetail, act *pullsAction) []layout.Widget { +func (v *pullView) checkRows(gtx C, th *ui.Theme, d backend.PRDetail, act *pullsAction) []layout.Widget { if len(d.CheckRuns) == 0 { return []layout.Widget{func(gtx C) D { return panelNotice(gtx, th, i18n.T("pulls.pullsChecks.none")) }} } diff --git a/native/cmd/lich-native/pulls_review_logic.go b/native/cmd/lich-native/pulls_review_logic.go index bc4324154..811439838 100644 --- a/native/cmd/lich-native/pulls_review_logic.go +++ b/native/cmd/lich-native/pulls_review_logic.go @@ -7,21 +7,21 @@ import ( "sync" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // timelineItem is one entry of the Conversation tab: exactly one of review, // comment and thread is set. type timelineItem struct { at time.Time - review *lichclient.PRReview - comment *lichclient.PRComment - thread *lichclient.PRReviewThread + review *backend.PRReview + comment *backend.PRComment + thread *backend.PRReviewThread } // conversationTimeline orders reviews, comments and the // open threads oldest first, the resolved threads apart, newest first. -func conversationTimeline(c *lichclient.PRConversation) (items []timelineItem, resolved []lichclient.PRReviewThread) { +func conversationTimeline(c *backend.PRConversation) (items []timelineItem, resolved []backend.PRReviewThread) { if c == nil { return nil, nil } @@ -40,7 +40,7 @@ func conversationTimeline(c *lichclient.PRConversation) (items []timelineItem, r items = append(items, timelineItem{at: threadStart(*t), thread: t}) } slices.SortStableFunc(items, func(a, b timelineItem) int { return a.at.Compare(b.at) }) - slices.SortStableFunc(resolved, func(a, b lichclient.PRReviewThread) int { return threadStart(b).Compare(threadStart(a)) }) + slices.SortStableFunc(resolved, func(a, b backend.PRReviewThread) int { return threadStart(b).Compare(threadStart(a)) }) return items, resolved } @@ -49,7 +49,7 @@ func parsedTime(iso string) time.Time { return t } -func threadStart(t lichclient.PRReviewThread) time.Time { +func threadStart(t backend.PRReviewThread) time.Time { if len(t.Comments) == 0 { return time.Time{} } @@ -58,7 +58,7 @@ func threadStart(t lichclient.PRReviewThread) time.Time { // threadTally is how the threads stand: open against all. An outdated thread // nobody resolved is open: rewriting the line is not answering the remark. -func threadTally(c *lichclient.PRConversation) (open, total int) { +func threadTally(c *backend.PRConversation) (open, total int) { if c == nil { return 0, 0 } @@ -86,13 +86,13 @@ func lineSpan(lines string) (start, line int) { // when the review is submitted. type pendingReview struct { mu sync.Mutex - comments []lichclient.ReviewComment + comments []backend.ReviewComment } func (r *pendingReview) add(path, lines, body string) { start, line := lineSpan(lines) r.mu.Lock() - r.comments = append(r.comments, lichclient.ReviewComment{Path: path, Line: line, StartLine: start, Side: "RIGHT", Body: body}) + r.comments = append(r.comments, backend.ReviewComment{Path: path, Line: line, StartLine: start, Side: "RIGHT", Body: body}) r.mu.Unlock() } @@ -120,7 +120,7 @@ func (r *pendingReview) clear() { } // drop removes the comments sent, one for each, and keeps any added since. -func (r *pendingReview) drop(sent []lichclient.ReviewComment) { +func (r *pendingReview) drop(sent []backend.ReviewComment) { r.mu.Lock() defer r.mu.Unlock() for _, c := range sent { @@ -130,7 +130,7 @@ func (r *pendingReview) drop(sent []lichclient.ReviewComment) { } } -func (r *pendingReview) snapshot() []lichclient.ReviewComment { +func (r *pendingReview) snapshot() []backend.ReviewComment { r.mu.Lock() defer r.mu.Unlock() return slices.Clone(r.comments) diff --git a/native/cmd/lich-native/pulls_review_logic_test.go b/native/cmd/lich-native/pulls_review_logic_test.go index 9400dc119..c4c58366b 100644 --- a/native/cmd/lich-native/pulls_review_logic_test.go +++ b/native/cmd/lich-native/pulls_review_logic_test.go @@ -3,18 +3,18 @@ package main import ( "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) -func thread(id, at string, resolved bool) lichclient.PRReviewThread { - return lichclient.PRReviewThread{ID: id, IsResolved: resolved, Comments: []lichclient.PRThreadComment{{Date: at}}} +func thread(id, at string, resolved bool) backend.PRReviewThread { + return backend.PRReviewThread{ID: id, IsResolved: resolved, Comments: []backend.PRThreadComment{{Date: at}}} } func TestConversationTimelineOrdersOldestFirstAndSetsResolvedApart(t *testing.T) { - c := &lichclient.PRConversation{ - Reviews: []lichclient.PRReview{{ID: "r", Date: "2026-01-03T00:00:00Z"}}, - Comments: []lichclient.PRComment{{Author: "c", Date: "2026-01-01T00:00:00Z"}}, - Threads: []lichclient.PRReviewThread{ + c := &backend.PRConversation{ + Reviews: []backend.PRReview{{ID: "r", Date: "2026-01-03T00:00:00Z"}}, + Comments: []backend.PRComment{{Author: "c", Date: "2026-01-01T00:00:00Z"}}, + Threads: []backend.PRReviewThread{ thread("open", "2026-01-02T00:00:00Z", false), thread("old", "2026-01-01T00:00:00Z", true), thread("new", "2026-01-05T00:00:00Z", true), diff --git a/native/cmd/lich-native/pulls_store.go b/native/cmd/lich-native/pulls_store.go index 6f479732e..f21197a50 100644 --- a/native/cmd/lich-native/pulls_store.go +++ b/native/cmd/lich-native/pulls_store.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // pendingRecheck is how often a pull request still settling is read again @@ -30,31 +30,31 @@ type pullsRead[T any] struct { // stale, one read per key at a time. type pullsStore struct { ctx context.Context - client *lichclient.Client + client *backend.Client invalidate func() mu sync.Mutex // staledAt is when every read was last owed a newer answer. staledAt time.Time - lists map[string]*pullsRead[[]lichclient.PRSummary] - details map[string]*pullsRead[*lichclient.PRDetail] - rules map[string]*pullsRead[*lichclient.BranchRules] + lists map[string]*pullsRead[[]backend.PRSummary] + details map[string]*pullsRead[*backend.PRDetail] + rules map[string]*pullsRead[*backend.BranchRules] conflicts map[string]*pullsRead[[]string] - checkouts map[string]*pullsRead[[]lichclient.Worktree] + checkouts map[string]*pullsRead[[]backend.Worktree] diffs map[string]*pullsRead[string] - talks map[string]*pullsRead[*lichclient.PRConversation] + talks map[string]*pullsRead[*backend.PRConversation] } -func newPullsStore(ctx context.Context, c *lichclient.Client, invalidate func()) *pullsStore { +func newPullsStore(ctx context.Context, c *backend.Client, invalidate func()) *pullsStore { return &pullsStore{ ctx: ctx, client: c, invalidate: invalidate, - lists: map[string]*pullsRead[[]lichclient.PRSummary]{}, - details: map[string]*pullsRead[*lichclient.PRDetail]{}, - rules: map[string]*pullsRead[*lichclient.BranchRules]{}, + lists: map[string]*pullsRead[[]backend.PRSummary]{}, + details: map[string]*pullsRead[*backend.PRDetail]{}, + rules: map[string]*pullsRead[*backend.BranchRules]{}, conflicts: map[string]*pullsRead[[]string]{}, - checkouts: map[string]*pullsRead[[]lichclient.Worktree]{}, + checkouts: map[string]*pullsRead[[]backend.Worktree]{}, diffs: map[string]*pullsRead[string]{}, - talks: map[string]*pullsRead[*lichclient.PRConversation]{}, + talks: map[string]*pullsRead[*backend.PRConversation]{}, } } @@ -76,7 +76,7 @@ func read[T any](s *pullsStore, entries map[string]*pullsRead[T], key string, lo e.loading, e.loaded, e.at = false, true, time.Now() e.val, e.err = val, "" if err != nil { - e.err = lichclient.ErrorText(err) + e.err = backend.ErrorText(err) } s.mu.Unlock() s.invalidate() @@ -113,22 +113,22 @@ func (s *pullsStore) stale() { s.invalidate() } -func (s *pullsStore) list(path string, state pullsState) pullsRead[[]lichclient.PRSummary] { - return read(s, s.lists, path+"\x00"+string(state), func() ([]lichclient.PRSummary, error) { +func (s *pullsStore) list(path string, state pullsState) pullsRead[[]backend.PRSummary] { + return read(s, s.lists, path+"\x00"+string(state), func() ([]backend.PRSummary, error) { return s.client.ListPullRequests(s.ctx, path, string(state)) }) } // detail reads pull request number, again every pendingRecheck while it is // still settling (stillSettling). -func (s *pullsStore) detail(path string, number int) pullsRead[*lichclient.PRDetail] { +func (s *pullsStore) detail(path string, number int) pullsRead[*backend.PRDetail] { key := fmt.Sprint(path, "\x00", number) s.mu.Lock() if e, ok := s.details[key]; ok && e.val != nil && stillSettling(*e.val) && time.Since(e.at) >= pendingRecheck { e.stale = true } s.mu.Unlock() - return read(s, s.details, key, func() (*lichclient.PRDetail, error) { + return read(s, s.details, key, func() (*backend.PRDetail, error) { return s.client.PullRequestDetail(s.ctx, path, number) }) } @@ -136,20 +136,20 @@ func (s *pullsStore) detail(path string, number int) pullsRead[*lichclient.PRDet // stillSettling is a pull request whose answer GitHub has not finished: a check // still running, or mergeability it reads UNKNOWN while it recomputes after a // push to the branch or its base. Nothing announces the end of either. -func stillSettling(d lichclient.PRDetail) bool { +func stillSettling(d backend.PRDetail) bool { if d.Checks.Pending > 0 { return true } return d.State == "OPEN" && (d.Mergeable == "UNKNOWN" || d.MergeStateStatus == "UNKNOWN") } -func (s *pullsStore) branchRules(path, base string) *lichclient.BranchRules { - return read(s, s.rules, path+"\x00"+base, func() (*lichclient.BranchRules, error) { +func (s *pullsStore) branchRules(path, base string) *backend.BranchRules { + return read(s, s.rules, path+"\x00"+base, func() (*backend.BranchRules, error) { return s.client.BranchRules(s.ctx, path, base) }).val } -func (s *pullsStore) conflictFiles(path string, d *lichclient.PRDetail) pullsRead[[]string] { +func (s *pullsStore) conflictFiles(path string, d *backend.PRDetail) pullsRead[[]string] { return read(s, s.conflicts, d.URL, func() ([]string, error) { return s.client.PullRequestConflicts(s.ctx, path, d.Number, d.BaseRefName, d.URL) }) @@ -161,27 +161,27 @@ func (s *pullsStore) diff(path string, number int) pullsRead[string] { }) } -func (s *pullsStore) conversation(path string, number int) pullsRead[*lichclient.PRConversation] { - return read(s, s.talks, fmt.Sprint(path, "\x00", number), func() (*lichclient.PRConversation, error) { +func (s *pullsStore) conversation(path string, number int) pullsRead[*backend.PRConversation] { + return read(s, s.talks, fmt.Sprint(path, "\x00", number), func() (*backend.PRConversation, error) { return s.client.PullRequestConversation(s.ctx, path, number) }) } -func (s *pullsStore) checkoutsOf(path string) []lichclient.Worktree { - return read(s, s.checkouts, path, func() ([]lichclient.Worktree, error) { +func (s *pullsStore) checkoutsOf(path string) []backend.Worktree { + return read(s, s.checkouts, path, func() ([]backend.Worktree, error) { return s.client.ListCheckouts(s.ctx, path) }).val } // freshCheckouts reads path's checkouts now, for an action that must not act // on a directory removed since the screen was drawn. -func (s *pullsStore) freshCheckouts(path string) ([]lichclient.Worktree, error) { +func (s *pullsStore) freshCheckouts(path string) ([]backend.Worktree, error) { list, err := s.client.ListCheckouts(s.ctx, path) if err != nil { return nil, err } s.mu.Lock() - s.checkouts[path] = &pullsRead[[]lichclient.Worktree]{val: list, loaded: true, at: time.Now()} + s.checkouts[path] = &pullsRead[[]backend.Worktree]{val: list, loaded: true, at: time.Now()} s.mu.Unlock() return list, nil } diff --git a/native/cmd/lich-native/pulls_store_test.go b/native/cmd/lich-native/pulls_store_test.go index fbf5068ed..b1acfa6e9 100644 --- a/native/cmd/lich-native/pulls_store_test.go +++ b/native/cmd/lich-native/pulls_store_test.go @@ -7,13 +7,13 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) func TestPullsStoreReadsOnceUntilAChangeIsAnnounced(t *testing.T) { stub := &rpcStub{answer: func(method string, _ []json.RawMessage) (any, int) { - return []lichclient.PRSummary{{Number: 7}}, http.StatusOK + return []backend.PRSummary{{Number: 7}}, http.StatusOK }} s := newPullsStore(context.Background(), stub.client(t), func() {}) s.list("/repo", pullsOpen) @@ -41,7 +41,7 @@ func TestPullsStoreKeepsGhsRefusalAsText(t *testing.T) { func TestPullsStoreRereadsADetailWhileAChecksRuns(t *testing.T) { stub := &rpcStub{answer: func(string, []json.RawMessage) (any, int) { - return lichclient.PRDetail{Number: 7, Checks: lichclient.ChecksRollup{Pending: 1, Total: 1}}, http.StatusOK + return backend.PRDetail{Number: 7, Checks: backend.ChecksRollup{Pending: 1, Total: 1}}, http.StatusOK }} s := newPullsStore(context.Background(), stub.client(t), func() {}) s.detail("/repo", 7) @@ -57,7 +57,7 @@ func TestPullsStoreRereadsADetailWhileAChecksRuns(t *testing.T) { func TestPullsStoreRereadsADetailWhileGitHubWorksOutMergeability(t *testing.T) { stub := &rpcStub{answer: func(string, []json.RawMessage) (any, int) { - return lichclient.PRDetail{Number: 7, State: "OPEN", Mergeable: "UNKNOWN", MergeStateStatus: "UNKNOWN"}, http.StatusOK + return backend.PRDetail{Number: 7, State: "OPEN", Mergeable: "UNKNOWN", MergeStateStatus: "UNKNOWN"}, http.StatusOK }} s := newPullsStore(context.Background(), stub.client(t), func() {}) s.detail("/repo", 7) @@ -74,10 +74,10 @@ func TestPullsStoreRereadsADetailWhileGitHubWorksOutMergeability(t *testing.T) { func pullsScreenOnAList(t *testing.T) (*pullsScreen, *rpcStub) { t.Helper() stub := &rpcStub{answer: func(string, []json.RawMessage) (any, int) { - return []lichclient.PRSummary{{Number: 7}}, http.StatusOK + return []backend.PRSummary{{Number: 7}}, http.StatusOK }} p := newPullsScreen(newPullsStore(context.Background(), stub.client(t), func() {}), new(ui.Root)) - p.show(lichclient.Project{ID: "p", Path: "/repo"}, "main", 0) + p.show(backend.Project{ID: "p", Path: "/repo"}, "main", 0) waitFor(t, "the first read", func() bool { return p.store.list("/repo", pullsOpen).loaded }) return p, stub } @@ -113,12 +113,12 @@ func TestPullsScreenRereadsWhatItShowsEveryRecheck(t *testing.T) { func TestPullRequestCommentsGoOnlyToASessionInItsBranchCheckout(t *testing.T) { stub := &rpcStub{answer: func(method string, _ []json.RawMessage) (any, int) { if method == "project.ListCheckouts" { - return []lichclient.Worktree{{Name: "main", Path: "/repo"}, {Name: "feat/x", Path: "/wt/feat-x"}}, http.StatusOK + return []backend.Worktree{{Name: "main", Path: "/repo"}, {Name: "feat/x", Path: "/wt/feat-x"}}, http.StatusOK } - return []lichclient.PRSummary{}, http.StatusOK + return []backend.PRSummary{}, http.StatusOK }} p := newPullsScreen(newPullsStore(context.Background(), stub.client(t), func() {}), new(ui.Root)) - p.show(lichclient.Project{ID: "p", Path: "/repo"}, "main", 0) + p.show(backend.Project{ID: "p", Path: "/repo"}, "main", 0) p.sessionsIn = func(projectID, path string) []string { if projectID == "p" && path == "/wt/feat-x" { return []string{"in-branch", "second"} @@ -127,18 +127,18 @@ func TestPullRequestCommentsGoOnlyToASessionInItsBranchCheckout(t *testing.T) { } waitFor(t, "the checkouts", func() bool { return len(p.store.checkoutsOf("/repo")) == 2 }) - if id, target := p.reviewSession(lichclient.PRDetail{HeadRefName: "feat/x", URL: "https://pr/1"}); id != "in-branch" || target != "/wt/feat-x" { + if id, target := p.reviewSession(backend.PRDetail{HeadRefName: "feat/x", URL: "https://pr/1"}); id != "in-branch" || target != "/wt/feat-x" { t.Errorf("checked-out branch: session %q, target %q; want in-branch, /wt/feat-x", id, target) } p.files.activeID = "second" - if id, _ := p.reviewSession(lichclient.PRDetail{HeadRefName: "feat/x"}); id != "second" { + if id, _ := p.reviewSession(backend.PRDetail{HeadRefName: "feat/x"}); id != "second" { t.Errorf("active session in the checkout: session %q, want second", id) } p.files.activeID = "elsewhere" - if id, _ := p.reviewSession(lichclient.PRDetail{HeadRefName: "feat/x"}); id != "in-branch" { + if id, _ := p.reviewSession(backend.PRDetail{HeadRefName: "feat/x"}); id != "in-branch" { t.Errorf("active session in another checkout: session %q, want in-branch", id) } - if id, target := p.reviewSession(lichclient.PRDetail{HeadRefName: "feat/y", URL: "https://pr/2"}); id != "" || target != "https://pr/2" { + if id, target := p.reviewSession(backend.PRDetail{HeadRefName: "feat/y", URL: "https://pr/2"}); id != "" || target != "https://pr/2" { t.Errorf("branch with no checkout: session %q, target %q; want none, the PR's page", id, target) } } diff --git a/native/cmd/lich-native/pulls_submit.go b/native/cmd/lich-native/pulls_submit.go index 5859f602f..842ebf904 100644 --- a/native/cmd/lich-native/pulls_submit.go +++ b/native/cmd/lich-native/pulls_submit.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -48,7 +48,7 @@ type submitState struct { // reviewButton is Approve while nothing is pending, else the "Submit review // N" menu of verdicts, which opens the dialog. -func (v *pullView) reviewButton(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, busy, blocked string, act *pullsAction) D { +func (v *pullView) reviewButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, busy, blocked string, act *pullsAction) D { pending := len(v.pendingOf(d.URL).snapshot()) if pending == 0 { label := i18n.T("pulls.pullRequestView.approve") @@ -92,7 +92,7 @@ func (u *window) layoutSubmitDialog(gtx C, th *ui.Theme) { s.verdict = "" } open := s.verdict != "" && p.open && p.selected != 0 - var d *lichclient.PRDetail + var d *backend.PRDetail if open { d = p.store.detail(p.project.Path, p.selected).val open = d != nil @@ -137,7 +137,7 @@ func ctrlEnter(gtx C, ed *widget.Editor) bool { func keyFocus(ed *widget.Editor) key.FocusCmd { return key.FocusCmd{Tag: ed} } -func (u *window) submitPR(path string, d lichclient.PRDetail, verdict, summary string, review *pendingReview) { +func (u *window) submitPR(path string, d backend.PRDetail, verdict, summary string, review *pendingReview) { v := &u.pulls.view v.setBusy("submit") defer v.setBusy("") @@ -147,7 +147,7 @@ func (u *window) submitPR(path string, d lichclient.PRDetail, verdict, summary s } sent := review.snapshot() if err := u.client.SubmitReview(u.ctx, path, d.Number, verdict, summary, head, sent); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pullRequestView.reviewFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pullRequestView.reviewFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } review.drop(sent) @@ -166,7 +166,7 @@ func (u *window) applyTalk(path string, number int, act pullsAction) { if act.comment != "" { body := act.comment go u.prWrite("comment", func(err error) string { - return i18n.T("pulls.pullsConversation.commentFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + return i18n.T("pulls.pullsConversation.commentFailed", i18n.Args{"error": backend.ErrorText(err)}) }, func() error { if err := u.client.CommentOnPullRequest(u.ctx, path, number, body); err != nil { return err @@ -178,7 +178,7 @@ func (u *window) applyTalk(path string, number int, act pullsAction) { if r := act.reply; r != nil { reply := *r go u.prWrite("reply:"+reply.thread, func(err error) string { - return i18n.T("pulls.reviewThread.replyFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + return i18n.T("pulls.reviewThread.replyFailed", i18n.Args{"error": backend.ErrorText(err)}) }, func() error { if err := u.client.ReplyToReviewThread(u.ctx, path, number, reply.commentID, reply.body); err != nil { return err @@ -196,7 +196,7 @@ func (u *window) applyTalk(path string, number int, act pullsAction) { if r := act.resolve; r != nil { resolve := *r go u.prWrite("resolve:"+resolve.id, func(err error) string { - args := i18n.Args{"error": lichclient.ErrorText(err)} + args := i18n.Args{"error": backend.ErrorText(err)} if resolve.resolved { return i18n.T("pulls.reviewThread.resolveFailed", args) } diff --git a/native/cmd/lich-native/pulls_view.go b/native/cmd/lich-native/pulls_view.go index e670a3bd4..04a2c8ed6 100644 --- a/native/cmd/lich-native/pulls_view.go +++ b/native/cmd/lich-native/pulls_view.go @@ -12,8 +12,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -34,9 +34,9 @@ var pullsTitleMinWidth = unit.Dp(320) // pullsAction is what the screen asks of the window this frame. type pullsAction struct { - session *lichclient.PRDetail // open (or go to) a session on its branch - handoff string // ... and write this at its prompt - merge string // a method to merge with + session *backend.PRDetail // open (or go to) a session on its branch + handoff string // ... and write this at its prompt + merge string // a method to merge with admin bool approve bool refresh bool @@ -185,7 +185,7 @@ func (v *pullView) layout(gtx C, th *ui.Theme, p *pullsScreen, act *pullsAction) // hidden, then the pull request's title and stats with its actions at the // right on the title's line. An island too narrow for both on one line puts // the actions on their own line above the title. -func (v *pullView) withToolbar(gtx C, th *ui.Theme, p *pullsScreen, d *lichclient.PRDetail, act *pullsAction, w layout.Widget) D { +func (v *pullView) withToolbar(gtx C, th *ui.Theme, p *pullsScreen, d *backend.PRDetail, act *pullsAction, w layout.Widget) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.UniformInset(pullsToolbarPad).Layout(gtx, func(gtx C) D { @@ -239,7 +239,7 @@ func (v *pullView) toolbarRow(gtx C, th *ui.Theme, p *pullsScreen, fill, right l // recordActions lays the actions at the width they want on one line, for // withToolbar to place or to drop for the stacked layout. -func (v *pullView) recordActions(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction) (op.CallOp, image.Point) { +func (v *pullView) recordActions(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) (op.CallOp, image.Point) { macro := op.Record(gtx.Ops) gtx.Constraints.Min = image.Point{} size := v.actions(gtx, th, p, d, act).Size @@ -247,7 +247,7 @@ func (v *pullView) recordActions(gtx C, th *ui.Theme, p *pullsScreen, d lichclie } // heading is the title over its stats, its left edge on the body's px-6. -func (v *pullView) heading(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction, besideShowList bool) D { +func (v *pullView) heading(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction, besideShowList bool) D { left := pullsPadX - pullsToolbarPad if besideShowList { left = ui.Space(2) @@ -284,7 +284,7 @@ func (v *pullView) empty(gtx C, th *ui.Theme, p *pullsScreen, act *pullsAction) }) } -func (v *pullView) body(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail, act *pullsAction) D { +func (v *pullView) body(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Inset{Left: pullsPadX, Right: pullsPadX}.Layout(gtx, func(gtx C) D { @@ -317,7 +317,7 @@ func pullGlyph(gtx C, th *ui.Theme, ic *icons.Icon, size unit.Dp, fg color.NRGBA } // stats is the status line:state, branches, checks, mergeability, review. -func (v *pullView) stats(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail) D { +func (v *pullView) stats(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail) D { if v.checksStat.Clicked(gtx) { v.showTab(d, tabChecks) } @@ -353,7 +353,7 @@ func (v *pullView) stats(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDet return ui.Wrap{GapX: ui.Space(4), GapY: ui.Space(1.5)}.Layout(gtx, items...) } -func stateStat(gtx C, th *ui.Theme, d lichclient.PRDetail) D { +func stateStat(gtx C, th *ui.Theme, d backend.PRDetail) D { switch { case d.State == "MERGED": return stat(gtx, th, icPullMerged, i18n.T("pulls.pullsStats.merged"), th.MutedForeground) @@ -365,7 +365,7 @@ func stateStat(gtx C, th *ui.Theme, d lichclient.PRDetail) D { return stat(gtx, th, icPullRequests, i18n.T("pulls.pullsStats.open"), th.TonePass) } -func checksStat(th *ui.Theme, c lichclient.ChecksRollup) (*icons.Icon, string, color.NRGBA, bool) { +func checksStat(th *ui.Theme, c backend.ChecksRollup) (*icons.Icon, string, color.NRGBA, bool) { switch verdictOf(c) { case verdictFailed: return icPullX, i18n.T("pulls.pullsStats.checksFailing", i18n.Args{"failed": c.Failed, "total": c.Total}), th.Destructive, true @@ -377,7 +377,7 @@ func checksStat(th *ui.Theme, c lichclient.ChecksRollup) (*icons.Icon, string, c return nil, "", color.NRGBA{}, false } -func mergeableStat(th *ui.Theme, d lichclient.PRDetail) (*icons.Icon, string, color.NRGBA, bool) { +func mergeableStat(th *ui.Theme, d backend.PRDetail) (*icons.Icon, string, color.NRGBA, bool) { switch { case d.State != "OPEN": return nil, "", color.NRGBA{}, false @@ -411,7 +411,7 @@ func reviewStat(th *ui.Theme, decision string, open, total int) (*icons.Icon, st // tabsOf is the tabs a pull request shows, in order: Checks only when it has // any. -func tabsOf(d lichclient.PRDetail) []pullsTab { +func tabsOf(d backend.PRDetail) []pullsTab { tabs := []pullsTab{tabOverview, tabConversation, tabCommits, tabFiles} if d.Checks.Total > 0 { tabs = append(tabs, tabChecks) @@ -421,7 +421,7 @@ func tabsOf(d lichclient.PRDetail) []pullsTab { // openTab is the tab on screen; one the PR does not show falls back to // Overview. -func (v *pullView) openTab(d lichclient.PRDetail) pullsTab { +func (v *pullView) openTab(d backend.PRDetail) pullsTab { tabs := tabsOf(d) if v.tabs.Selected < 0 || v.tabs.Selected >= len(tabs) { return tabOverview @@ -429,7 +429,7 @@ func (v *pullView) openTab(d lichclient.PRDetail) pullsTab { return tabs[v.tabs.Selected] } -func (v *pullView) showTab(d lichclient.PRDetail, tab pullsTab) { +func (v *pullView) showTab(d backend.PRDetail, tab pullsTab) { for i, t := range tabsOf(d) { if t == tab { v.tabs.Selected = i @@ -451,7 +451,7 @@ func (t pullsTab) label() string { return i18n.T("pulls.pullRequestView.tab.overview") } -func (v *pullView) tabStrip(gtx C, th *ui.Theme, p *pullsScreen, d lichclient.PRDetail) D { +func (v *pullView) tabStrip(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail) D { items, resolved := conversationTimeline(p.store.conversation(p.project.Path, d.Number).val) counts := map[pullsTab]int{tabConversation: len(items) + len(resolved), tabCommits: len(d.Commits), tabFiles: d.ChangedFiles, tabChecks: d.Checks.Total} var triggers []ui.TabsTrigger diff --git a/native/cmd/lich-native/resume_dialog.go b/native/cmd/lich-native/resume_dialog.go index bdda563f7..2c16b2ae5 100644 --- a/native/cmd/lich-native/resume_dialog.go +++ b/native/cmd/lich-native/resume_dialog.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -21,7 +21,7 @@ type resumeDialog struct { remember widget.Bool // shown is the session last asked about, kept so the closing fade still // reads it; a new one unticks the box. - shown lichclient.Session + shown backend.Session provider string rememberLabel widget.Clickable startNew widget.Clickable diff --git a/native/cmd/lich-native/sessiontooltip.go b/native/cmd/lich-native/sessiontooltip.go index 242b18855..5a449ff72 100644 --- a/native/cmd/lich-native/sessiontooltip.go +++ b/native/cmd/lich-native/sessiontooltip.go @@ -7,8 +7,8 @@ import ( "gioui.org/font" "gioui.org/layout" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -150,7 +150,7 @@ func branchSection(th *ui.Theme, sv *sessionView) layout.Widget { // baseSections spell out the base readout: commits behind the // named base, then the conflict, then the first files it is in. -func baseSections(th *ui.Theme, b *lichclient.BaseStatus) []layout.Widget { +func baseSections(th *ui.Theme, b *backend.BaseStatus) []layout.Widget { if b == nil { return nil } diff --git a/native/cmd/lich-native/settings.go b/native/cmd/lich-native/settings.go index 973901ef8..15ce48e82 100644 --- a/native/cmd/lich-native/settings.go +++ b/native/cmd/lich-native/settings.go @@ -16,10 +16,10 @@ import ( "gioui.org/widget" "gioui.org/widget/material" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/fonts" "github.com/lichdotdev/lich/native/gioterm" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -51,14 +51,14 @@ var ( // reads and writes, as the footer does. type settingsScreen struct { ctx context.Context - client *lichclient.Client + client *backend.Client root *ui.Root invalidate func() // applyEditor hands the review panel the Editor preference at key that // just changed. applyEditor func(key string, prefs editorPrefs) // promptUpdate is the update gate's prompt, for the manual check. - promptUpdate func(lichclient.AppUpdateStatus) + promptUpdate func(backend.AppUpdateStatus) open bool section string @@ -95,7 +95,7 @@ type settingsScreen struct { providers providerSettings } -func newSettingsScreen(ctx context.Context, c *lichclient.Client, root *ui.Root, invalidate func()) *settingsScreen { +func newSettingsScreen(ctx context.Context, c *backend.Client, root *ui.Root, invalidate func()) *settingsScreen { s := &settingsScreen{ctx: ctx, client: c, root: root, invalidate: invalidate, section: defaultSection, resets: map[string]*widget.Clickable{}, scrollTo: -1} s.providers.book = newProviderBook(c, invalidate) diff --git a/native/cmd/lich-native/settings_editor.go b/native/cmd/lich-native/settings_editor.go index b159e121a..34bdd6243 100644 --- a/native/cmd/lich-native/settings_editor.go +++ b/native/cmd/lich-native/settings_editor.go @@ -7,8 +7,8 @@ import ( "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -36,7 +36,7 @@ func parseDiffLayout(raw string) string { return diffUnified } -func loadEditorPrefs(ctx context.Context, c *lichclient.Client) (editorPrefs, error) { +func loadEditorPrefs(ctx context.Context, c *backend.Client) (editorPrefs, error) { hide, err := loadHideWhitespace(ctx, c) if err != nil { return editorPrefs{}, err diff --git a/native/cmd/lich-native/settings_help.go b/native/cmd/lich-native/settings_help.go index 516e425a0..22c5f2e3c 100644 --- a/native/cmd/lich-native/settings_help.go +++ b/native/cmd/lich-native/settings_help.go @@ -8,8 +8,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -25,7 +25,7 @@ const ( // bugReportURL is the new-issue link prefilled from the diagnostics. The query // keys are the ids in .github/ISSUE_TEMPLATE/bug.yml; GitHub ignores a key that // matches no field. -func bugReportURL(d lichclient.Diagnostics) string { +func bugReportURL(d backend.Diagnostics) string { q := url.Values{"template": {helpIssueTemplate}, "version": {d.Version}, "platform": {d.Platform}} return helpRepoURL + "/issues/new?" + q.Encode() } @@ -34,13 +34,13 @@ func bugReportURL(d lichclient.Diagnostics) string { // answers, read once per window, and its buttons. type helpSettings struct { mu sync.Mutex - diagnostics *lichclient.Diagnostics + diagnostics *backend.Diagnostics asked bool bug, reveal, repo widget.Clickable } -func (h *helpSettings) read(s *settingsScreen) *lichclient.Diagnostics { +func (h *helpSettings) read(s *settingsScreen) *backend.Diagnostics { h.mu.Lock() defer h.mu.Unlock() if !h.asked { @@ -99,7 +99,7 @@ func (s *settingsScreen) helpRow(gtx C, th *ui.Theme, e settingEntry, r settingR } // logLine is the Open log folder button and the log's path beside it. -func (h *helpSettings) logLine(gtx C, th *ui.Theme, d *lichclient.Diagnostics) D { +func (h *helpSettings) logLine(gtx C, th *ui.Theme, d *backend.Diagnostics) D { path := i18n.T("settings.helpSettings.noLogFile") if d != nil && d.LogPath != "" { path = d.LogPath @@ -124,7 +124,7 @@ func (s *settingsScreen) revealLog() { func revealLogOutcome(id string, err error) ui.Toast { t := ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("settings.helpSettings.logFolderOpened")} if err != nil { - t = ui.Toast{Variant: ui.ToastError, Title: i18n.T("settings.settingsHelp.logFailed", i18n.Args{"error": lichclient.ErrorText(err)})} + t = ui.Toast{Variant: ui.ToastError, Title: i18n.T("settings.settingsHelp.logFailed", i18n.Args{"error": backend.ErrorText(err)})} } t.ID = id return t diff --git a/native/cmd/lich-native/settings_help_test.go b/native/cmd/lich-native/settings_help_test.go index a6f4a5706..ff84c87b5 100644 --- a/native/cmd/lich-native/settings_help_test.go +++ b/native/cmd/lich-native/settings_help_test.go @@ -5,14 +5,14 @@ import ( "strings" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) // TestBugReportURLPrefillsTheForm pins the query keys to the field ids of // .github/ISSUE_TEMPLATE/bug.yml. func TestBugReportURLPrefillsTheForm(t *testing.T) { - got := bugReportURL(lichclient.Diagnostics{Version: "1.2.3", Platform: "linux/amd64"}) + got := bugReportURL(backend.Diagnostics{Version: "1.2.3", Platform: "linux/amd64"}) want := "https://github.com/lichdotdev/lich/issues/new?platform=linux%2Famd64&template=bug.yml&version=1.2.3" if got != want { t.Fatalf("bugReportURL = %s, want %s", got, want) diff --git a/native/cmd/lich-native/settings_language.go b/native/cmd/lich-native/settings_language.go index dbc6a3a31..47e16f736 100644 --- a/native/cmd/lich-native/settings_language.go +++ b/native/cmd/lich-native/settings_language.go @@ -12,9 +12,9 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/fonts" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -73,7 +73,7 @@ func launchLanguage(l i18n.Locale, draws func(i18n.Locale) bool) (shown i18n.Loc // loadLanguages reads both languages: the interface's is the stored choice, // or system's before anyone made one; the prompt language is English when // absent, as the backend reads it. -func loadLanguages(ctx context.Context, c *lichclient.Client, system func() i18n.Locale) (iface, prompt i18n.Locale, err error) { +func loadLanguages(ctx context.Context, c *backend.Client, system func() i18n.Locale) (iface, prompt i18n.Locale, err error) { stored, err := c.GetSetting(ctx, uiLanguageKey, "") if err != nil { return "", "", err @@ -165,7 +165,7 @@ func (s *settingsScreen) choosePromptLanguage(l i18n.Locale) { if i18n.PromptLanguage() == l { i18n.SetPromptLanguage(previous) } - title := i18n.T("settings.language.promptSaveFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + title := i18n.T("settings.language.promptSaveFailed", i18n.Args{"error": backend.ErrorText(err)}) s.theme.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: title}) s.invalidate() }() diff --git a/native/cmd/lich-native/settings_profiles.go b/native/cmd/lich-native/settings_profiles.go index 46de6db97..b2c4cca2d 100644 --- a/native/cmd/lich-native/settings_profiles.go +++ b/native/cmd/lich-native/settings_profiles.go @@ -7,8 +7,8 @@ import ( "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -40,7 +40,7 @@ type flowData struct { kind flowKind open bool step flowStep - target lichclient.Profile + target backend.Profile // fresh asks the frame to empty the inputs, which only it may touch; // refresh asks it to read Settings again, which a delete made stale. fresh bool @@ -54,11 +54,11 @@ type flowData struct { authenticating bool failure string account string - results []lichclient.ProfilePluginResult + results []backend.ProfilePluginResult // pluginNote is why the lich plugin did not install, in the backend's // words. pluginNote string - plugins []lichclient.ProfilePlugin + plugins []backend.ProfilePlugin source string } @@ -97,7 +97,7 @@ func (f *profileFlow) set(change func(d *flowData)) { // checkedKeys are the plugins the checklist has ticked, in the list's order. // A plugin is ticked until the user unticks it. -func (f *profileFlow) checkedKeys(plugins []lichclient.ProfilePlugin) []string { +func (f *profileFlow) checkedKeys(plugins []backend.ProfilePlugin) []string { var keys []string for _, p := range plugins { if c := f.checks[p.Key]; c == nil || c.Value { @@ -188,7 +188,7 @@ func (s *settingsScreen) startNewProfile(c *profileSettings) { } // startRename, startDelete and startCopy open the dialog of their name on p. -func (s *settingsScreen) startRename(c *profileSettings, p lichclient.Profile) { +func (s *settingsScreen) startRename(c *profileSettings, p backend.Profile) { f := &c.flow f.set(func(d *flowData) { *d = flowData{kind: flowRename, open: true, fresh: true, target: p} }) f.name.SetText(p.Name) @@ -196,13 +196,13 @@ func (s *settingsScreen) startRename(c *profileSettings, p lichclient.Profile) { s.invalidate() } -func (s *settingsScreen) startDelete(c *profileSettings, p lichclient.Profile) { +func (s *settingsScreen) startDelete(c *profileSettings, p backend.Profile) { f := &c.flow f.set(func(d *flowData) { *d = flowData{kind: flowDelete, open: true, target: p} }) s.invalidate() } -func (s *settingsScreen) startCopy(c *profileSettings, p lichclient.Profile) { +func (s *settingsScreen) startCopy(c *profileSettings, p backend.Profile) { f := &c.flow list, _ := c.book.snapshot() source := defaultCopySource(list, p.Slug) @@ -213,7 +213,7 @@ func (s *settingsScreen) startCopy(c *profileSettings, p lichclient.Profile) { // defaultCopySource is where a copy reads from unless asked otherwise: // Personal, or the first other profile when Personal is the target. -func defaultCopySource(list []lichclient.Profile, target string) string { +func defaultCopySource(list []backend.Profile, target string) string { if target != personalProfile { return personalProfile } @@ -264,7 +264,7 @@ func (s *settingsScreen) confirmFlow(c *profileSettings, d flowData) { // failFlow puts the dialog back on its form with the backend's sentence. func (s *settingsScreen) failFlow(c *profileSettings, err error) { - text := lichclient.ErrorText(err) + text := backend.ErrorText(err) c.flow.set(func(d *flowData) { d.step, d.failure = stepForm, text }) s.invalidate() } @@ -284,7 +284,7 @@ func (s *settingsScreen) createProfile(c *profileSettings, name string, keys []s if view := f.view(); !view.open || view.kind != flowNew { return } - s.startLogin(c, lichclient.Profile{Slug: p.Slug, Name: p.Name}, keys) + s.startLogin(c, backend.Profile{Slug: p.Slug, Name: p.Name}, keys) }() } @@ -294,7 +294,7 @@ func (s *settingsScreen) renameProfile(c *profileSettings, slug, name string) { d.step, d.working, d.failure = stepWorking, i18n.T("settings.settingsProfiles.renaming"), "" }) go func() { - if _, err := c.provider.api(s.client).Update(s.ctx, slug, lichclient.ProfileUpdate{Name: name}); err != nil { + if _, err := c.provider.api(s.client).Update(s.ctx, slug, backend.ProfileUpdate{Name: name}); err != nil { s.failFlow(c, err) return } @@ -318,7 +318,7 @@ func (s *settingsScreen) deleteProfile(c *profileSettings, slug string) { }() } -func (s *settingsScreen) copyPlugins(c *profileSettings, target lichclient.Profile, keys []string) { +func (s *settingsScreen) copyPlugins(c *profileSettings, target backend.Profile, keys []string) { f := &c.flow f.set(func(d *flowData) { d.step, d.working, d.failure = stepWorking, i18n.T("settings.settingsProfiles.installing"), "" @@ -336,7 +336,7 @@ func (s *settingsScreen) copyPlugins(c *profileSettings, target lichclient.Profi // startLogin runs the login of p in the dialog and installs keys into it once // it is through. It is called from any goroutine. -func (s *settingsScreen) startLogin(c *profileSettings, p lichclient.Profile, keys []string) { +func (s *settingsScreen) startLogin(c *profileSettings, p backend.Profile, keys []string) { f := &c.flow if c.provider.terminalLogin { s.startTerminalLogin(c, p) @@ -351,7 +351,7 @@ func (s *settingsScreen) startLogin(c *profileSettings, p lichclient.Profile, ke f.set(func(d *flowData) { switch { case err != nil: - d.step, d.failure = stepResult, lichclient.ErrorText(err) + d.step, d.failure = stepResult, backend.ErrorText(err) case d.step == stepWorking: d.step = stepWaiting } @@ -363,7 +363,7 @@ func (s *settingsScreen) startLogin(c *profileSettings, p lichclient.Profile, ke // startTerminalLogin hands the login of p to a shell card, the only place a // provider with terminalLogin runs one, and closes the dialog. Where there is no // card to open it in, the dialog ends on the failure. -func (s *settingsScreen) startTerminalLogin(c *profileSettings, p lichclient.Profile) { +func (s *settingsScreen) startTerminalLogin(c *profileSettings, p backend.Profile) { f := &c.flow f.set(func(d *flowData) { *d = flowData{kind: flowLogin, open: true, fresh: true, step: stepWorking, working: i18n.T("settings.settingsProfiles.startingLogin"), target: p} @@ -373,7 +373,7 @@ func (s *settingsScreen) startTerminalLogin(c *profileSettings, p lichclient.Pro command, err := c.provider.api(s.client).LoginCommand(s.ctx, p.Slug) switch { case err != nil: - f.set(func(d *flowData) { d.step, d.failure = stepResult, lichclient.ErrorText(err) }) + f.set(func(d *flowData) { d.step, d.failure = stepResult, backend.ErrorText(err) }) case c.openTerminal == nil || !c.openTerminal(command): f.set(func(d *flowData) { d.step, d.failure = stepResult, i18n.T("settings.settingsProfiles.noTerminal") }) default: @@ -386,7 +386,7 @@ func (s *settingsScreen) startTerminalLogin(c *profileSettings, p lichclient.Pro // loginProgress takes one step of a login of the provider kind names, as the // backend reports it: the sign-in address while the CLI waits, then how it // ended. -func (s *settingsScreen) loginProgress(kind string, ev lichclient.ProfileLogin) { +func (s *settingsScreen) loginProgress(kind string, ev backend.ProfileLogin) { c := s.providers.profiles(kind) if c == nil { return @@ -419,20 +419,20 @@ func (s *settingsScreen) loginProgress(kind string, ev lichclient.ProfileLogin) // finishLogin reads who is logged in now, then brings the plugins that were // asked for: they need the login, so they come last. -func (s *settingsScreen) finishLogin(c *profileSettings, d flowData, ev lichclient.ProfileLogin) { +func (s *settingsScreen) finishLogin(c *profileSettings, d flowData, ev backend.ProfileLogin) { c.book.planChanged(d.target.Slug) s.reloadProfiles(c) account := "" if p, ok := c.book.find(d.target.Slug); ok { account = loggedInAs(p) } - var results []lichclient.ProfilePluginResult + var results []backend.ProfilePluginResult if len(d.keys) > 0 { c.flow.set(func(d *flowData) { d.step, d.working = stepWorking, i18n.T("settings.settingsProfiles.installing") }) s.invalidate() var err error if results, err = c.provider.api(s.client).InstallPlugins(s.ctx, d.target.Slug, d.keys); err != nil { - ev.PluginError = lichclient.ErrorText(err) + ev.PluginError = backend.ErrorText(err) } } c.flow.set(func(d *flowData) { diff --git a/native/cmd/lich-native/settings_profiles_dialogs.go b/native/cmd/lich-native/settings_profiles_dialogs.go index 63b3ccff2..2faceea92 100644 --- a/native/cmd/lich-native/settings_profiles_dialogs.go +++ b/native/cmd/lich-native/settings_profiles_dialogs.go @@ -12,8 +12,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -58,7 +58,7 @@ func flowHeading(prov profileProvider, d flowData) (title, desc string) { } // loggedInAs is the line a finished login opens its result with. -func loggedInAs(p lichclient.Profile) string { +func loggedInAs(p backend.Profile) string { if p.Email == "" { return i18n.T("settings.profiles.loggedIn") } @@ -168,7 +168,7 @@ func (s *settingsScreen) submitLoginCode(c *profileSettings, slug, code string) c.flow.set(func(d *flowData) { d.authenticating, d.failure = true, "" }) go func() { if err := c.provider.api(s.client).LoginCode(s.ctx, slug, code); err != nil { - c.flow.set(func(d *flowData) { d.authenticating, d.failure = false, lichclient.ErrorText(err) }) + c.flow.set(func(d *flowData) { d.authenticating, d.failure = false, backend.ErrorText(err) }) s.invalidate() } }() @@ -268,7 +268,7 @@ func (s *settingsScreen) copySource(gtx C, th *ui.Theme, f *profileFlow, d flowD ) } -func (s *settingsScreen) sourceOption(gtx C, th *ui.Theme, f *profileFlow, p lichclient.Profile, chosen bool) D { +func (s *settingsScreen) sourceOption(gtx C, th *ui.Theme, f *profileFlow, p backend.Profile, chosen bool) D { if f.options == nil { f.options = map[string]*widget.Clickable{} } @@ -345,7 +345,7 @@ func lockedPlugin(gtx C, th *ui.Theme) D { }) } -func (s *settingsScreen) pluginList(gtx C, th *ui.Theme, f *profileFlow, plugins []lichclient.ProfilePlugin) D { +func (s *settingsScreen) pluginList(gtx C, th *ui.Theme, f *profileFlow, plugins []backend.ProfilePlugin) D { if f.checks == nil { f.checks = map[string]*widget.Bool{} } @@ -362,7 +362,7 @@ func (s *settingsScreen) pluginList(gtx C, th *ui.Theme, f *profileFlow, plugins }) } -func pluginRow(gtx C, th *ui.Theme, p lichclient.ProfilePlugin, box *widget.Bool) D { +func pluginRow(gtx C, th *ui.Theme, p backend.ProfilePlugin, box *widget.Bool) D { return layout.Inset{Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(th.Checkbox(box).Layout), @@ -460,7 +460,7 @@ func (s *settingsScreen) resultBody(gtx C, th *ui.Theme, d flowData) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } -func pluginResult(gtx C, th *ui.Theme, r lichclient.ProfilePluginResult) D { +func pluginResult(gtx C, th *ui.Theme, r backend.ProfilePluginResult) D { mark, ink := icDlgOK, th.TonePass if !r.OK { mark, ink = icDlgBan, th.Destructive diff --git a/native/cmd/lich-native/settings_profiles_look.go b/native/cmd/lich-native/settings_profiles_look.go index 5e262aee0..c7f0b4bd7 100644 --- a/native/cmd/lich-native/settings_profiles_look.go +++ b/native/cmd/lich-native/settings_profiles_look.go @@ -3,8 +3,8 @@ package main import ( "log" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -17,9 +17,9 @@ var ( // profileLookEntries are a profile row's Color grid, Choose image… and, while // it has one, Remove image. The grid is the card's, but its first swatch is the // profile's own derived color rather than the theme. -func (s *settingsScreen) profileLookEntries(c *profileSettings, p lichclient.Profile) []ui.MenuEntry { +func (s *settingsScreen) profileLookEntries(c *profileSettings, p backend.Profile) []ui.MenuEntry { swatches := colorSwatches(p.Color, func(name string) { - go s.updateLook(c, p.Slug, lichclient.ProfileUpdate{Color: &name}) + go s.updateLook(c, p.Slug, backend.ProfileUpdate{Color: &name}) }) swatches[0].Swatch, swatches[0].Label = profileColor(p.Slug, ""), i18n.T("settings.settingsProfilesView.automaticColor") entries := []ui.MenuEntry{ @@ -30,7 +30,7 @@ func (s *settingsScreen) profileLookEntries(c *profileSettings, p lichclient.Pro none := "" entries = append(entries, ui.MenuEntry{ Label: i18n.T("settings.settingsProfilesView.removeImage"), Icon: icProfileNoImage, - OnClick: func() { go s.updateLook(c, p.Slug, lichclient.ProfileUpdate{Image: &none}) }, + OnClick: func() { go s.updateLook(c, p.Slug, backend.ProfileUpdate{Image: &none}) }, }) } return entries @@ -43,11 +43,11 @@ func (s *settingsScreen) chooseProfileImage(c *profileSettings, slug string) { return } if path != "" { - s.updateLook(c, slug, lichclient.ProfileUpdate{Image: &path}) + s.updateLook(c, slug, backend.ProfileUpdate{Image: &path}) } } -func (s *settingsScreen) updateLook(c *profileSettings, slug string, u lichclient.ProfileUpdate) { +func (s *settingsScreen) updateLook(c *profileSettings, slug string, u backend.ProfileUpdate) { if _, err := c.provider.api(s.client).Update(s.ctx, slug, u); err != nil { s.lookFailed(slug, err) } diff --git a/native/cmd/lich-native/settings_profiles_look_test.go b/native/cmd/lich-native/settings_profiles_look_test.go index b99184758..88950495c 100644 --- a/native/cmd/lich-native/settings_profiles_look_test.go +++ b/native/cmd/lich-native/settings_profiles_look_test.go @@ -8,7 +8,8 @@ import ( "strings" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -33,7 +34,7 @@ func TestPickingAProfileColorSendsIt(t *testing.T) { b := &profileBackend{} s := flowScreen(t, b) c := &s.providers.claude - color, ok := lookEntry(s.profileLookEntries(c, lichclient.Profile{Slug: "work", Name: "Work"}), "Color") + color, ok := lookEntry(s.profileLookEntries(c, backend.Profile{Slug: "work", Name: "Work"}), "Color") if !ok { t.Fatal("the menu has no Color") } @@ -48,9 +49,9 @@ func TestPickingAProfileColorSendsIt(t *testing.T) { func TestAProfileChangedEventRedrawsTheProfileKeepingItsLogin(t *testing.T) { m := newModel(nil, func() {}) - m.profiles.list = []lichclient.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Work", LoggedIn: true, Email: "w@x.y"}} + m.profiles.list = []backend.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Work", LoggedIn: true, Email: "w@x.y"}} m.profiles.images = map[string]*avatarImage{} - m.profileChanged(lichclient.ProfileChanged{Provider: claudeKind, Profile: lichclient.Profile{Slug: "work", Name: "Client", Color: "teal", Image: tinyPNG(t)}}) + m.profileChanged(backend.ProfileChanged{Provider: claudeKind, Profile: api.Profile{Slug: "work", Name: "Client", Color: "teal", Image: tinyPNG(t)}}) look := m.profiles.look("work") if look.name != "Client" || look.color != "teal" || look.image == nil { t.Errorf("look = %+v", look) @@ -58,13 +59,13 @@ func TestAProfileChangedEventRedrawsTheProfileKeepingItsLogin(t *testing.T) { if work, _ := m.profiles.find("work"); !work.LoggedIn || work.Email != "w@x.y" { t.Errorf("the event dropped the login state: %+v", work) } - m.profileChanged(lichclient.ProfileChanged{Provider: "gemini", Profile: lichclient.Profile{Slug: "work"}}) + m.profileChanged(backend.ProfileChanged{Provider: "gemini", Profile: api.Profile{Slug: "work"}}) } func TestTheProfileMenuOffersRemoveImageOnlyWithAnImage(t *testing.T) { s := flowScreen(t, &profileBackend{}) c := &s.providers.claude - plain := lichclient.Profile{Slug: "work", Name: "Work"} + plain := backend.Profile{Slug: "work", Name: "Work"} if _, ok := lookEntry(s.profileLookEntries(c, plain), "Remove image"); ok { t.Error("Remove image offered on a profile without one") } @@ -81,7 +82,7 @@ func TestTheProfileMenuOffersRemoveImageOnlyWithAnImage(t *testing.T) { } func TestAnImageTheWindowCannotDecodeShowsTheInitial(t *testing.T) { - if decodeAvatar(lichclient.Profile{Slug: "work", Image: []byte("not a png")}) != nil { + if decodeAvatar(backend.Profile{Slug: "work", Image: []byte("not a png")}) != nil { t.Error("garbage decoded as an image") } if profileColor("work", "chartreuse") != projectSwatch("work") { diff --git a/native/cmd/lich-native/settings_profiles_shot.go b/native/cmd/lich-native/settings_profiles_shot.go index 4e0d82aed..c8c7a5481 100644 --- a/native/cmd/lich-native/settings_profiles_shot.go +++ b/native/cmd/lich-native/settings_profiles_shot.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -28,7 +28,7 @@ const ( // showProfileDialog puts the dialog in the state a -shot asks for, with // nothing sent to the backend beyond what opening it reads. func (s *settingsScreen) showProfileDialog(name string) error { - target := lichclient.Profile{Slug: "work", Name: "Work", LoggedIn: true} + target := backend.Profile{Slug: "work", Name: "Work", LoggedIn: true} c := s.providers.profiles(*flagSettingsProvider) if c == nil { c = &s.providers.claude @@ -55,9 +55,9 @@ func (s *settingsScreen) showProfileDialog(name string) error { *d = flowData{kind: flowLogin, open: true, step: stepWaiting, target: target, url: url} }) case "result": - var results []lichclient.ProfilePluginResult + var results []backend.ProfilePluginResult if c.provider.plugins { - results = []lichclient.ProfilePluginResult{ + results = []backend.ProfilePluginResult{ {Key: "frontend-design@claude-plugins-official", OK: true}, {Key: "security-guidance@acme-internal", Message: "Plugin installation is blocked by your organization's policy."}, } @@ -80,9 +80,9 @@ func (b *profileBook) seedProfilePlans(spec string) error { if !ok || err != nil { return fmt.Errorf("-profile-usage: want slug=percent, got %q", pair) } - plan := lichclient.QuotaPlan{Provider: b.provider.kind, Status: "ok", - Windows: []lichclient.QuotaWindow{{Label: "5h", Seconds: sessionWindowSeconds, Percent: percent}}} - b.quota.seed(profileQuotaKey(b.provider, slug), []lichclient.QuotaPlan{plan}) + plan := backend.QuotaPlan{Provider: b.provider.kind, Status: "ok", + Windows: []backend.QuotaWindow{{Label: "5h", Seconds: sessionWindowSeconds, Percent: percent}}} + b.quota.seed(profileQuotaKey(b.provider, slug), []backend.QuotaPlan{plan}) } return nil } @@ -119,7 +119,7 @@ func (u *window) openShotMenu() { // seed puts a reading under key as if a read had landed, for the rigs that // draw a window without a backend answering. -func (q *planQuota) seed(key string, plans []lichclient.QuotaPlan) { +func (q *planQuota) seed(key string, plans []backend.QuotaPlan) { q.mu.Lock() defer q.mu.Unlock() e := q.entries[key] diff --git a/native/cmd/lich-native/settings_profiles_test.go b/native/cmd/lich-native/settings_profiles_test.go index 7e4179612..474b78c74 100644 --- a/native/cmd/lich-native/settings_profiles_test.go +++ b/native/cmd/lich-native/settings_profiles_test.go @@ -13,7 +13,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -39,7 +40,7 @@ func (b *profileBackend) log() []string { return slices.Clone(b.calls) } -func (b *profileBackend) client(t *testing.T) *lichclient.Client { +func (b *profileBackend) client(t *testing.T) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) @@ -47,9 +48,9 @@ func (b *profileBackend) client(t *testing.T) *lichclient.Client { var out any switch method { case "claudeprofile.CreateProfile", "codexprofile.CreateProfile", "ompprofile.CreateProfile", "kiroprofile.CreateProfile": - out = lichclient.Profile{Slug: "work", Name: "Work"} + out = backend.Profile{Slug: "work", Name: "Work"} case "claudeprofile.Plugins", "codexprofile.Plugins": - out = []lichclient.ProfilePlugin{{Key: "a@m", Name: "a", Marketplace: "m"}, {Key: "b@m", Name: "b", Marketplace: "m"}} + out = []backend.ProfilePlugin{{Key: "a@m", Name: "a", Marketplace: "m"}, {Key: "b@m", Name: "b", Marketplace: "m"}} case "ompprofile.LoginCommand": out = "env OMP_PROFILE='work' omp login" case "kiroprofile.LoginCommand": @@ -58,10 +59,10 @@ func (b *profileBackend) client(t *testing.T) *lichclient.Client { b.mu.Lock() in := b.loggedIn b.mu.Unlock() - out = []lichclient.Profile{{Slug: personalProfile, Name: "Personal", Personal: true, LoggedIn: true}, + out = []backend.Profile{{Slug: personalProfile, Name: "Personal", Personal: true, LoggedIn: true}, {Slug: "work", Name: "Work", LoggedIn: in, Email: "w@x.y", Plan: "max"}} case "claudeprofile.InstallPlugins", "codexprofile.InstallPlugins": - out = []lichclient.ProfilePluginResult{{Key: "a@m", OK: true}, {Key: "b@m", Message: "blocked by your organization"}} + out = []backend.ProfilePluginResult{{Key: "a@m", OK: true}, {Key: "b@m", Message: "blocked by your organization"}} case "claudeprofile.DeleteProfile": if b.deleteErr != "" { w.WriteHeader(http.StatusInternalServerError) @@ -85,7 +86,7 @@ func (b *profileBackend) client(t *testing.T) *lichclient.Client { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func flowScreen(t *testing.T, b *profileBackend) *settingsScreen { @@ -117,7 +118,7 @@ func TestNewProfileBringsItsPluginsOnlyOnceTheLoginIsThrough(t *testing.T) { s.confirmFlow(&s.providers.claude, f.view()) eventually(t, "the login is waiting", func() bool { return f.view().step == stepWaiting }) - s.loginProgress(claudeKind, lichclient.ProfileLogin{Profile: "work", Output: "visit", URL: "https://claude.com/oauth"}) + s.loginProgress(claudeKind, backend.ProfileLogin{Profile: "work", Output: "visit", URL: "https://claude.com/oauth"}) if got := f.view().url; got != "https://claude.com/oauth" { t.Errorf("address = %q", got) } @@ -128,14 +129,14 @@ func TestNewProfileBringsItsPluginsOnlyOnceTheLoginIsThrough(t *testing.T) { b.mu.Lock() b.loggedIn = true b.mu.Unlock() - s.loginProgress(claudeKind, lichclient.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) + s.loginProgress(claudeKind, backend.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) eventually(t, "the result", func() bool { return f.view().step == stepResult }) d := f.view() if d.account != "Logged in as w@x.y · Max" || len(d.results) != 2 || d.failure != "" { t.Errorf("result = %+v", d) } - want := []string{`claudeprofile.CreateProfile["Work"]`, `claudeprofile.Login["work"]`, `claudeprofile.InstallPlugins["work",["a@m","b@m"]]`} + want := []string{`claudeprofile.CreateProfile[{"name":"Work"}]`, `claudeprofile.Login[{"slug":"work"}]`, `claudeprofile.InstallPlugins[{"slug":"work","keys":["a@m","b@m"]}]`} if got := b.log(); !slices.Equal(got, want) { t.Errorf("calls = %v, want %v", got, want) } @@ -145,16 +146,16 @@ func TestCancellingAWaitingLoginEndsItAndClosesTheDialog(t *testing.T) { b := &profileBackend{} s := flowScreen(t, b) f := &s.providers.claude.flow - s.startLogin(&s.providers.claude, lichclient.Profile{Slug: "work", Name: "Work"}, nil) + s.startLogin(&s.providers.claude, backend.Profile{Slug: "work", Name: "Work"}, nil) eventually(t, "waiting", func() bool { return f.view().step == stepWaiting }) s.dismissFlow(&s.providers.claude, f.view()) if f.view().open { t.Error("the dialog stayed open") } eventually(t, "the backend was told", func() bool { - return slices.Contains(b.log(), `claudeprofile.CancelLogin["work"]`) + return slices.Contains(b.log(), `claudeprofile.CancelLogin[{"slug":"work"}]`) }) - s.loginProgress(claudeKind, lichclient.ProfileLogin{Profile: "work", Done: true, Cancelled: true, Error: "login cancelled"}) + s.loginProgress(claudeKind, backend.ProfileLogin{Profile: "work", Done: true, Cancelled: true, Error: "login cancelled"}) if f.view().open { t.Error("the cancelled event reopened it") } @@ -164,9 +165,9 @@ func TestALoginThatDidNotTakeShowsItsReason(t *testing.T) { b := &profileBackend{} s := flowScreen(t, b) f := &s.providers.claude.flow - s.startLogin(&s.providers.claude, lichclient.Profile{Slug: "work", Name: "Work"}, []string{"a@m"}) + s.startLogin(&s.providers.claude, backend.Profile{Slug: "work", Name: "Work"}, []string{"a@m"}) eventually(t, "waiting", func() bool { return f.view().step == stepWaiting }) - s.loginProgress(claudeKind, lichclient.ProfileLogin{Profile: "work", Done: true, Error: "claude auth login: exit status 1"}) + s.loginProgress(claudeKind, backend.ProfileLogin{Profile: "work", Done: true, Error: "claude auth login: exit status 1"}) d := f.view() if d.step != stepResult || d.failure != "claude auth login: exit status 1" { t.Errorf("flow = %+v", d) @@ -186,14 +187,14 @@ func TestASubmittedCodeShowsItIsBeingChecked(t *testing.T) { b := &profileBackend{} s := flowScreen(t, b) f := &s.providers.claude.flow - s.startLogin(&s.providers.claude, lichclient.Profile{Slug: "work", Name: "Work"}, nil) + s.startLogin(&s.providers.claude, backend.Profile{Slug: "work", Name: "Work"}, nil) eventually(t, "waiting", func() bool { return f.view().step == stepWaiting }) s.submitLoginCode(&s.providers.claude, "work", " abc ") if !f.view().authenticating { t.Error("the dialog did not show the code was sent") } eventually(t, "the code reached the login", func() bool { - return slices.Contains(b.log(), `claudeprofile.LoginCode["work","abc"]`) + return slices.Contains(b.log(), `claudeprofile.LoginCode[{"slug":"work","code":"abc"}]`) }) if !f.view().authenticating { t.Error("a code the login took stopped being checked") @@ -205,17 +206,17 @@ func TestALoginPastTheBrowserShowsItIsAuthenticating(t *testing.T) { s := codexFlowScreen(t, b) c := &s.providers.codex f := &c.flow - s.startLogin(c, lichclient.Profile{Slug: "work", Name: "Work"}, nil) + s.startLogin(c, backend.Profile{Slug: "work", Name: "Work"}, nil) eventually(t, "waiting", func() bool { return f.view().step == stepWaiting }) - s.loginProgress(claudeKind, lichclient.ProfileLogin{Profile: "work", Authenticating: true}) + s.loginProgress(claudeKind, backend.ProfileLogin{Profile: "work", Authenticating: true}) if f.view().authenticating { t.Error("a Claude login step reached the Codex dialog") } - s.loginProgress(codexKind, lichclient.ProfileLogin{Profile: "other", Authenticating: true}) + s.loginProgress(codexKind, backend.ProfileLogin{Profile: "other", Authenticating: true}) if f.view().authenticating { t.Error("another profile's login reached this dialog") } - s.loginProgress(codexKind, lichclient.ProfileLogin{Profile: "work", Authenticating: true}) + s.loginProgress(codexKind, backend.ProfileLogin{Profile: "work", Authenticating: true}) if !f.view().authenticating { t.Error("the dialog did not show the login is authenticating") } @@ -225,7 +226,7 @@ func TestACodeTheLoginRefusesBringsTheFieldBack(t *testing.T) { b := &profileBackend{loginCodeErr: "profile \"work\" is not logging in"} s := flowScreen(t, b) f := &s.providers.claude.flow - s.startLogin(&s.providers.claude, lichclient.Profile{Slug: "work", Name: "Work"}, nil) + s.startLogin(&s.providers.claude, backend.Profile{Slug: "work", Name: "Work"}, nil) eventually(t, "waiting", func() bool { return f.view().step == stepWaiting }) s.submitLoginCode(&s.providers.claude, "work", "abc") eventually(t, "the refusal", func() bool { return f.view().failure != "" }) @@ -238,7 +239,7 @@ func TestADeleteTheBackendRefusesStaysOnTheForm(t *testing.T) { b := &profileBackend{deleteErr: `profile "work" has 1 running session(s); close them before deleting it`} s := flowScreen(t, b) f := &s.providers.claude.flow - s.startDelete(&s.providers.claude, lichclient.Profile{Slug: "work", Name: "Work"}) + s.startDelete(&s.providers.claude, backend.Profile{Slug: "work", Name: "Work"}) s.confirmFlow(&s.providers.claude, f.view()) eventually(t, "the refusal", func() bool { return f.view().failure != "" }) d := f.view() @@ -248,7 +249,7 @@ func TestADeleteTheBackendRefusesStaysOnTheForm(t *testing.T) { } func TestCopySourceIsPersonalUnlessPersonalIsTheTarget(t *testing.T) { - list := []lichclient.Profile{{Slug: personalProfile}, {Slug: "work"}, {Slug: "side"}} + list := []backend.Profile{{Slug: personalProfile}, {Slug: "work"}, {Slug: "side"}} if got := defaultCopySource(list, "work"); got != personalProfile { t.Errorf("into work: %q", got) } @@ -316,18 +317,18 @@ func TestTheProfileKeysAreReadPerProjectAndSearchable(t *testing.T) { func TestResumeNamesTheProfileTheConversationLivesIn(t *testing.T) { m := newModel(nil, func() {}) - m.profiles.list = []lichclient.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Work", Email: "w@x.y"}} - if got := m.resumeProfileLine(lichclient.Session{Kind: claudeKind}); got != "" { + m.profiles.list = []backend.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Work", Email: "w@x.y"}} + if got := m.resumeProfileLine(backend.Session{Kind: claudeKind}); got != "" { t.Errorf("Personal said %q", got) } - if got := m.resumeProfileLine(lichclient.Session{Kind: "crush", Profile: "work"}); got != "" { + if got := m.resumeProfileLine(backend.Session{Kind: "crush", Profile: "work"}); got != "" { t.Errorf("a provider with no profiles said %q", got) } - m.codexProfiles.list = []lichclient.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Codex Work"}} - if got := m.resumeProfileLine(lichclient.Session{Kind: codexKind, Profile: "work"}); got != "It resumes in Codex Work, the profile it started in." { + m.codexProfiles.list = []backend.Profile{{Slug: personalProfile, Name: "Personal"}, {Slug: "work", Name: "Codex Work"}} + if got := m.resumeProfileLine(backend.Session{Kind: codexKind, Profile: "work"}); got != "It resumes in Codex Work, the profile it started in." { t.Errorf("a Codex card said %q", got) } - got := m.resumeProfileLine(lichclient.Session{Kind: claudeKind, Profile: "work"}) + got := m.resumeProfileLine(backend.Session{Kind: claudeKind, Profile: "work"}) if got != "It resumes in Work · w@x.y, the profile it started in." { t.Errorf("line = %q", got) } @@ -335,12 +336,12 @@ func TestResumeNamesTheProfileTheConversationLivesIn(t *testing.T) { func TestContinueInOffersOnlyOtherLoggedInProfiles(t *testing.T) { m := newModel(nil, func() {}) - m.profiles.list = []lichclient.Profile{ + m.profiles.list = []backend.Profile{ {Slug: personalProfile, Name: "Personal", LoggedIn: true}, {Slug: "work", Name: "Work", LoggedIn: true}, {Slug: "side", Name: "Side"}, } - m.quota.seed(profileQuotaKey(claudeProfileProvider, "work"), []lichclient.QuotaPlan{{Provider: claudeKind, Status: "ok", Windows: []lichclient.QuotaWindow{{Seconds: 5 * hour, Percent: 100}}}}) + m.quota.seed(profileQuotaKey(claudeProfileProvider, "work"), []backend.QuotaPlan{{Provider: claudeKind, Status: "ok", Windows: []backend.QuotaWindow{{Seconds: 5 * hour, Percent: 100}}}}) got := m.continueTargets(claudeKind, "") if len(got) != 1 || got[0].slug != "work" || got[0].usage != "limit" || !got[0].limited { t.Errorf("from Personal = %+v", got) @@ -359,7 +360,7 @@ func TestContinueInOffersOnlyOtherLoggedInProfiles(t *testing.T) { func TestSessionProfileEventMovesTheCard(t *testing.T) { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Sessions: []lichclient.Session{{ID: "s1", Kind: claudeKind}}}} + m.projects = []backend.Project{{ID: "p", Sessions: []backend.Session{{ID: "s1", Kind: claudeKind}}}} m.setProfile("s1", "work") if got := m.projects[0].Sessions[0].Profile; got != "work" { t.Errorf("profile = %q", got) @@ -376,13 +377,13 @@ func TestADeletedProfileClosesTheDialogAndHasSettingsReadAgain(t *testing.T) { b := &profileBackend{} s := flowScreen(t, b) f := &s.providers.claude.flow - s.startDelete(&s.providers.claude, lichclient.Profile{Slug: "work", Name: "Work"}) + s.startDelete(&s.providers.claude, backend.Profile{Slug: "work", Name: "Work"}) s.confirmFlow(&s.providers.claude, f.view()) eventually(t, "the delete reached the backend", func() bool { return !f.view().open }) if !f.view().refresh { t.Error("a project set to the deleted profile would keep showing it: Settings was not asked to read again") } - if !slices.Contains(b.log(), `claudeprofile.DeleteProfile["work"]`) { + if !slices.Contains(b.log(), `claudeprofile.DeleteProfile[{"slug":"work"}]`) { t.Errorf("calls = %v", b.log()) } } @@ -403,7 +404,7 @@ func TestANewProfileDialogOpensOnAnEmptyName(t *testing.T) { func TestSettingsFollowsThePlanReadingsItDraws(t *testing.T) { r := newQuotaRig() - r.set([]lichclient.QuotaPlan{quotaPlan()}, nil) + r.set([]backend.QuotaPlan{quotaPlan()}, nil) s := flowScreen(t, &profileBackend{}) s.footer = &footer{quota: r.q} s.open, s.section = true, sectionProviders @@ -466,18 +467,18 @@ func TestACodexProfileBringsPluginsAndHasNoCodeToPaste(t *testing.T) { s.confirmFlow(c, f.view()) eventually(t, "the login is waiting", func() bool { return f.view().step == stepWaiting }) - s.loginProgress(claudeKind, lichclient.ProfileLogin{Profile: "work", Output: "x", URL: "https://claude.com/oauth"}) + s.loginProgress(claudeKind, backend.ProfileLogin{Profile: "work", Output: "x", URL: "https://claude.com/oauth"}) if got := f.view().url; got != "" { t.Errorf("a Claude login step reached the Codex dialog: %q", got) } - s.loginProgress(codexKind, lichclient.ProfileLogin{Profile: "work", Output: "visit", URL: "https://auth.openai.com/oauth/authorize"}) + s.loginProgress(codexKind, backend.ProfileLogin{Profile: "work", Output: "visit", URL: "https://auth.openai.com/oauth/authorize"}) if got := f.view().url; got != "https://auth.openai.com/oauth/authorize" { t.Errorf("address = %q", got) } - s.loginProgress(codexKind, lichclient.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) + s.loginProgress(codexKind, backend.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) eventually(t, "the result", func() bool { return f.view().step == stepResult }) - want := []string{`codexprofile.CreateProfile["Work"]`, `codexprofile.Login["work"]`, `codexprofile.InstallPlugins["work",["a@m","b@m"]]`} + want := []string{`codexprofile.CreateProfile[{"name":"Work"}]`, `codexprofile.Login[{"slug":"work"}]`, `codexprofile.InstallPlugins[{"slug":"work","keys":["a@m","b@m"]}]`} if got := b.log(); !slices.Equal(got, want) { t.Errorf("calls = %v, want %v", got, want) } @@ -494,22 +495,22 @@ func TestCodexProfilesCarryAPluginSetAndNameTheirAccount(t *testing.T) { if !codexProfileProvider.plugins || codexProfileProvider.loginCode { t.Error("Codex profiles are supposed to have a plugin set of their own and no code to paste") } - if got := profileAccountLine(codexProfileProvider, lichclient.Profile{LoggedIn: true}); got != "no plugins" { + if got := profileAccountLine(codexProfileProvider, backend.Profile{LoggedIn: true}); got != "no plugins" { t.Errorf("a login whose account could not be read: %q", got) } - if got := profileAccountLine(codexProfileProvider, lichclient.Profile{LoggedIn: true, Email: "a@b.c", Plan: "plus", Plugins: 2}); got != "a@b.c · Plus · 2 plugins" { + if got := profileAccountLine(codexProfileProvider, backend.Profile{LoggedIn: true, Email: "a@b.c", Plan: "plus", Plugins: 2}); got != "a@b.c · Plus · 2 plugins" { t.Errorf("account line = %q", got) } } func TestContinueInOffersCodexProfilesOfCodexCards(t *testing.T) { m := newModel(nil, func() {}) - m.codexProfiles.list = []lichclient.Profile{ + m.codexProfiles.list = []backend.Profile{ {Slug: personalProfile, Name: "Personal", LoggedIn: true}, {Slug: "work", Name: "Work", LoggedIn: true}, } - m.profiles.list = []lichclient.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}} - m.quota.seed(profileQuotaKey(codexProfileProvider, "work"), []lichclient.QuotaPlan{{Provider: codexKind, Status: "ok", Windows: []lichclient.QuotaWindow{{Seconds: 5 * hour, Percent: 40}}}}) + m.profiles.list = []backend.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}} + m.quota.seed(profileQuotaKey(codexProfileProvider, "work"), []backend.QuotaPlan{{Provider: codexKind, Status: "ok", Windows: []backend.QuotaWindow{{Seconds: 5 * hour, Percent: 40}}}}) got := m.continueTargets(codexKind, "") if len(got) != 1 || got[0].slug != "work" || got[0].usage != "40% used" { @@ -533,7 +534,7 @@ func TestEachProvidersProfileReadingHasItsOwnKey(t *testing.T) { if profileQuotaKey(codexProfileProvider, personalProfile) != "" || profileQuotaKey(codexProfileProvider, "") != "" { t.Error("Codex's Personal reading is not the machine's") } - if loginEventKind(lichclient.CodexProfileLoginEvent) != codexKind || loginEventKind(lichclient.ClaudeProfileLoginEvent) != claudeKind { + if loginEventKind(api.CodexProfileLoginEvent.Name) != codexKind || loginEventKind(api.ClaudeProfileLoginEvent.Name) != claudeKind { t.Error("a login event was routed to the wrong provider") } } @@ -567,7 +568,7 @@ func TestAnOMPProfileLogsInFromATerminalAndNeverRunsALogin(t *testing.T) { if want := "env OMP_PROFILE='work' omp login"; ran[0] != want { t.Errorf("command = %q, want %q", ran[0], want) } - want := []string{`ompprofile.CreateProfile["Work"]`, `ompprofile.LoginCommand["work"]`} + want := []string{`ompprofile.CreateProfile[{"name":"Work"}]`, `ompprofile.LoginCommand[{"slug":"work"}]`} if got := b.log(); !slices.Equal(got, want) { t.Errorf("calls = %v, want %v", got, want) } @@ -578,7 +579,7 @@ func TestAnOMPLoginWithNoCardToOpenEndsOnTheFailure(t *testing.T) { s := ompFlowScreen(t, b, func(string) bool { return false }) c := &s.providers.omp f := &c.flow - s.startLogin(c, lichclient.Profile{Slug: "work", Name: "Work"}, nil) + s.startLogin(c, backend.Profile{Slug: "work", Name: "Work"}, nil) eventually(t, "the failure", func() bool { return f.view().step == stepResult }) if d := f.view(); !d.open || d.failure != "Open a project to log in from a terminal." { t.Errorf("dialog = %+v, want it open on %q", d, "Open a project to log in from a terminal.") @@ -592,17 +593,17 @@ func TestOMPProfilesHaveNoPlanAndNameTheirAccounts(t *testing.T) { if got := profileQuotaKey(ompProfileProvider, "work"); got != "" { t.Errorf("an omp profile has a plan reading %q", got) } - line := profileAccountLine(ompProfileProvider, lichclient.Profile{ + line := profileAccountLine(ompProfileProvider, backend.Profile{ LoggedIn: true, Email: "a@b.c, d@e.f", Providers: []string{"anthropic", "openai-codex"}, }) if line != "a@b.c, d@e.f · anthropic, openai-codex" { t.Errorf("account line = %q", line) } - book := newProfileBook(ompProfileProvider, nil, func() {}, newPlanQuota(func(string) ([]lichclient.QuotaPlan, error) { + book := newProfileBook(ompProfileProvider, nil, func() {}, newPlanQuota(func(string) ([]backend.QuotaPlan, error) { t.Error("a plan was read for oh-my-pi") return nil, nil }, func() {})) - book.list = []lichclient.Profile{{Slug: personalProfile, LoggedIn: true}, {Slug: "work", LoggedIn: true}} + book.list = []backend.Profile{{Slug: personalProfile, LoggedIn: true}, {Slug: "work", LoggedIn: true}} book.followQuoted() if len(book.quoted) != 0 { t.Errorf("readings followed for a provider with no plan: %v", book.quoted) diff --git a/native/cmd/lich-native/settings_profiles_view.go b/native/cmd/lich-native/settings_profiles_view.go index e9d70e0cc..c19ce9eaa 100644 --- a/native/cmd/lich-native/settings_profiles_view.go +++ b/native/cmd/lich-native/settings_profiles_view.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -97,7 +97,7 @@ func (s *settingsScreen) profilePicker(gtx C, th *ui.Theme, c *profileSettings, // profileOption is one SelectItem of the picker: the avatar and the name, // checked when chosen. -func (s *settingsScreen) profileOption(gtx C, th *ui.Theme, c *profileSettings, p lichclient.Profile, chosen bool) D { +func (s *settingsScreen) profileOption(gtx C, th *ui.Theme, c *profileSettings, p backend.Profile, chosen bool) D { if c.options == nil { c.options = map[string]*widget.Clickable{} } @@ -161,7 +161,7 @@ func (s *settingsScreen) profileRows(gtx C, th *ui.Theme, c *profileSettings) D // profileListRow is one profile: its mark and name, the account it is logged // in to or the amber word that it is not, and its menu. -func (s *settingsScreen) profileListRow(gtx C, th *ui.Theme, c *profileSettings, p lichclient.Profile, first bool) D { +func (s *settingsScreen) profileListRow(gtx C, th *ui.Theme, c *profileSettings, p backend.Profile, first bool) D { st := c.row(p.Slug) if st.login.Clicked(gtx) { s.startLogin(c, p, nil) @@ -190,7 +190,7 @@ func (s *settingsScreen) profileListRow(gtx C, th *ui.Theme, c *profileSettings, } // profileText is a row's name and the line under it. -func profileText(gtx C, th *ui.Theme, prov profileProvider, p lichclient.Profile) D { +func profileText(gtx C, th *ui.Theme, prov profileProvider, p backend.Profile) D { name := []layout.FlexChild{layout.Rigid(th.Text(ui.TextSM, p.Name).Weight(font.Medium).Layout)} if p.Personal { name = append(name, ui.Gap(1.5), layout.Rigid(th.Text(ui.TextXS, i18n.T("settings.settingsProfilesView.defaultTag")).Muted().Layout)) @@ -207,7 +207,7 @@ func profileText(gtx C, th *ui.Theme, prov profileProvider, p lichclient.Profile return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } -func profileStatusLine(gtx C, th *ui.Theme, prov profileProvider, p lichclient.Profile) D { +func profileStatusLine(gtx C, th *ui.Theme, prov profileProvider, p backend.Profile) D { switch { case p.LoggedIn: return th.Text(ui.TextXS, profileAccountLine(prov, p)).Muted().Layout(gtx) @@ -219,7 +219,7 @@ func profileStatusLine(gtx C, th *ui.Theme, prov profileProvider, p lichclient.P // profileMenu is a row's ⋯: logging in again, bringing plugins over and // renaming for every profile, deleting for every one but Personal. -func (s *settingsScreen) profileMenu(gtx C, th *ui.Theme, c *profileSettings, p lichclient.Profile, st *profileRowState) D { +func (s *settingsScreen) profileMenu(gtx C, th *ui.Theme, c *profileSettings, p backend.Profile, st *profileRowState) D { entries := []ui.MenuEntry{{Label: i18n.T("settings.settingsProfilesView.logInAgain"), Icon: icProfileLogIn, OnClick: func() { s.startLogin(c, p, nil) }}} if c.provider.plugins { entries = append(entries, ui.MenuEntry{Label: i18n.T("settings.settingsProfilesView.copyFrom"), Icon: icProfileCopy, Disabled: !p.LoggedIn, OnClick: func() { s.startCopy(c, p) }}) @@ -242,15 +242,15 @@ func (s *settingsScreen) profileMenu(gtx C, th *ui.Theme, c *profileSettings, p // profilePlanBlock is Plan usage for the profile the open scope uses: Personal // is the machine's own reading, any other profile is asked for by itself. -func (s *settingsScreen) profilePlanBlock(gtx C, th *ui.Theme, c *profileSettings, values scopedValues, slug string, plan lichclient.QuotaPlan, first bool) D { +func (s *settingsScreen) profilePlanBlock(gtx C, th *ui.Theme, c *profileSettings, values scopedValues, slug string, plan backend.QuotaPlan, first bool) D { title := i18n.T("settings.planUsageSetting.title") + profileAccountSplit + c.book.nameOf(slug) return s.planBlock(gtx, th, values, plan, first, title) } // profilePlan is the reading of the profile slug, the one the pane watches. -func (s *settingsScreen) profilePlan(c *profileSettings, slug string) (lichclient.QuotaPlan, bool) { +func (s *settingsScreen) profilePlan(c *profileSettings, slug string) (backend.QuotaPlan, bool) { if s.footer == nil { - return lichclient.QuotaPlan{}, false + return backend.QuotaPlan{}, false } return planFor(s.footer.quota.plans(profileQuotaKey(c.provider, slug)), c.provider.kind) } diff --git a/native/cmd/lich-native/settings_providers.go b/native/cmd/lich-native/settings_providers.go index 86762e2ef..0aeeadcee 100644 --- a/native/cmd/lich-native/settings_providers.go +++ b/native/cmd/lich-native/settings_providers.go @@ -8,8 +8,8 @@ import ( "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -308,9 +308,9 @@ func parkedLabel(scope string, project, global binLayer, projectName string) str // binCheckLabel is the verdict beside a path. A missing binary reads // differently by what was asked for: a bare name was looked up on $PATH and a // path was looked for where it was written. -func binCheckLabel(c lichclient.BinaryCheck, bin string) string { +func binCheckLabel(c backend.BinaryCheck, bin string) string { switch c.Status { - case lichclient.CheckOK: + case backend.CheckOK: return i18n.T("env.binary.executable") case "not-found": if strings.ContainsAny(bin, `/\`) { @@ -329,7 +329,7 @@ func binCheckLabel(c lichclient.BinaryCheck, bin string) string { // binCheckDetail is the sentence under a failing binary: what will happen and // what to do about it. -func binCheckDetail(c lichclient.BinaryCheck) string { +func binCheckDetail(c backend.BinaryCheck) string { switch c.Status { case "home-shortcut": return i18n.T("env.binary.detailHomeShortcut") @@ -354,7 +354,7 @@ func installSummary(p providerInfo) string { // planSummary is the plan reading in the width a list row has, "" for a // provider lich reads no plan from and while the first reading is in flight. // A signed-out reading is words: the row is where a lapsed login shows. -func planSummary(plan lichclient.QuotaPlan, ok bool) string { +func planSummary(plan backend.QuotaPlan, ok bool) string { if !ok { return "" } @@ -404,7 +404,7 @@ type providerSettings struct { // projectOff are the bin-off flags of the project being looked at, // "\x00" to the stored value. projectOff askOnce[string] - checks askOnce[lichclient.BinaryCheck] // a binary's verdict, by what was asked + checks askOnce[backend.BinaryCheck] // a binary's verdict, by what was asked // versions is what a binary reports and what else $PATH holds for its // provider, by "\x00". versions askOnce[binVersions] @@ -622,9 +622,9 @@ func (s *settingsScreen) planKeys(values scopedValues) []string { // planOf is the machine's reading for kind, false for a provider lich reads no // plan from and until the first reading lands. -func (s *settingsScreen) planOf(kind string) (lichclient.QuotaPlan, bool) { +func (s *settingsScreen) planOf(kind string) (backend.QuotaPlan, bool) { if s.footer == nil { - return lichclient.QuotaPlan{}, false + return backend.QuotaPlan{}, false } return planFor(s.footer.quota.plans(""), kind) } diff --git a/native/cmd/lich-native/settings_providers_bin.go b/native/cmd/lich-native/settings_providers_bin.go index 45a094200..e5cfe678b 100644 --- a/native/cmd/lich-native/settings_providers_bin.go +++ b/native/cmd/lich-native/settings_providers_bin.go @@ -7,8 +7,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -78,8 +78,8 @@ func (p *providerSettings) projectOffOf(s *settingsScreen, scope, kind string) b // check is what bin resolves to as a spawn would, ok false until the backend // answered. One question per distinct string, so a path typed one letter at a // time is asked once per letter and never twice. -func (p *providerSettings) check(s *settingsScreen, bin string) (lichclient.BinaryCheck, bool) { - return p.checks.get(&p.mu, bin, s.invalidate, func() (lichclient.BinaryCheck, bool) { +func (p *providerSettings) check(s *settingsScreen, bin string) (backend.BinaryCheck, bool) { + return p.checks.get(&p.mu, bin, s.invalidate, func() (backend.BinaryCheck, bool) { c, err := s.client.Verify(s.ctx, bin) if err != nil { log.Printf("settings: verify %s: %v", bin, err) @@ -114,7 +114,7 @@ func (s *settingsScreen) pickBin(m providersMachine, p providerInfo, scope strin file, err := s.client.PickFile(s.ctx, i18n.T("settings.providerBinary.pickTitle", i18n.Args{"provider": p.name})) if err != nil { log.Printf("settings: pick %s binary: %v", p.id, err) - title := i18n.T("settings.settingsProvidersBin.pickFailed", i18n.Args{"error": lichclient.ErrorText(err)}) + title := i18n.T("settings.settingsProvidersBin.pickFailed", i18n.Args{"error": backend.ErrorText(err)}) s.theme.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: title}) return } @@ -200,7 +200,7 @@ func spawnedBin(project, global binLayer, binary string) (scope, configured, ask // binAlsoOnPathLine is the muted sentence naming the other versions $PATH // holds, nothing when it holds none. -func binAlsoOnPathLine(gtx C, th *ui.Theme, others []lichclient.ProviderBinary) D { +func binAlsoOnPathLine(gtx C, th *ui.Theme, others []backend.ProviderBinary) D { line := alsoOnPathLine(others) if line == "" { return D{} @@ -241,7 +241,7 @@ func binSource(scope, projectName string) string { // binVerdict is the glyph of what the path resolves to, the executable it // resolved to, the version it reports and where it came from. Nothing is drawn for the glyph while a // check is in flight, so a field being typed into does not blink. -func binVerdict(gtx C, th *ui.Theme, check lichclient.BinaryCheck, known bool, asked, configured, version, source string) D { +func binVerdict(gtx C, th *ui.Theme, check backend.BinaryCheck, known bool, asked, configured, version, source string) D { path := check.Path if path == "" { path = configured @@ -253,13 +253,13 @@ func binVerdict(gtx C, th *ui.Theme, check lichclient.BinaryCheck, known bool, a switch { case known && check.Failed(): children = append(children, layout.Rigid(func(gtx C) D { return icBinFailed.Layout(gtx, binGlyphSize, th.Destructive) }), ui.Gap(1.5)) - case known && check.Status == lichclient.CheckOK: + case known && check.Status == backend.CheckOK: children = append(children, layout.Rigid(func(gtx C) D { return icBinOK.Layout(gtx, binGlyphSize, th.TonePass) }), ui.Gap(1.5)) } children = append(children, layout.Rigid(th.Text(ui.TextXS, path).Mono().Layout), ) - if version != "" && known && check.Status == lichclient.CheckOK { + if version != "" && known && check.Status == backend.CheckOK { children = append(children, ui.Gap(2), layout.Rigid(th.Text(ui.TextXS, version).Mono().Muted().Layout)) } children = append(children, @@ -271,7 +271,7 @@ func binVerdict(gtx C, th *ui.Theme, check lichclient.BinaryCheck, known bool, a // binProblem is the sentence under a binary a session cannot start from: what // is wrong and what will happen. -func binProblem(gtx C, th *ui.Theme, check lichclient.BinaryCheck, known bool, asked string) D { +func binProblem(gtx C, th *ui.Theme, check backend.BinaryCheck, known bool, asked string) D { if !known || !check.Failed() { return D{} } diff --git a/native/cmd/lich-native/settings_providers_plan.go b/native/cmd/lich-native/settings_providers_plan.go index a82e1c76e..f0d5f921d 100644 --- a/native/cmd/lich-native/settings_providers_plan.go +++ b/native/cmd/lich-native/settings_providers_plan.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" "gioui.org/unit" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -25,7 +25,7 @@ var ( // the state of the provider above everything that configures it. It is a // reading, not a setting, so it has no scope of its own. heading is the title // it is drawn under, "settings.planUsageSetting.title" when "". -func (s *settingsScreen) planBlock(gtx C, th *ui.Theme, values scopedValues, plan lichclient.QuotaPlan, first bool, heading string) D { +func (s *settingsScreen) planBlock(gtx C, th *ui.Theme, values scopedValues, plan backend.QuotaPlan, first bool, heading string) D { r := s.providerRow(gtx, settingEntry{titleKey: "settings.planUsageSetting.title"}, values) r.kind, r.overriddenIn = rowPlain, nil r.description = i18n.T("settings.planUsageSetting.description") @@ -35,7 +35,7 @@ func (s *settingsScreen) planBlock(gtx C, th *ui.Theme, values scopedValues, pla return r.layoutBlock(gtx, th, first, func(gtx C, th *ui.Theme) D { return planBody(gtx, th, plan) }) } -func planBody(gtx C, th *ui.Theme, plan lichclient.QuotaPlan) D { +func planBody(gtx C, th *ui.Theme, plan backend.QuotaPlan) D { switch plan.Status { case "signed-out": return planSignedOutLine(gtx, th, loginCommands[plan.Provider]) diff --git a/native/cmd/lich-native/settings_providers_test.go b/native/cmd/lich-native/settings_providers_test.go index fdeced31a..7e486908f 100644 --- a/native/cmd/lich-native/settings_providers_test.go +++ b/native/cmd/lich-native/settings_providers_test.go @@ -5,8 +5,8 @@ import ( "slices" "testing" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -129,17 +129,17 @@ func TestBinaryVerdictWordsFollowWhatWasAskedFor(t *testing.T) { cases := []struct { status, bin, label, detail string }{ - {lichclient.CheckOK, "claude", "executable", ""}, + {backend.CheckOK, "claude", "executable", ""}, {"not-found", "claude", "not on $PATH", i18n.T("env.binary.detailBroken")}, {"not-found", "/opt/claude", "no such file", i18n.T("env.binary.detailBroken")}, {"not-found", `C:\bin\claude.exe`, "no such file", i18n.T("env.binary.detailBroken")}, {"not-executable", "/opt/claude", "not executable", i18n.T("env.binary.detailBroken")}, {"home-shortcut", "~/claude", "~ is not expanded", i18n.T("env.binary.detailHomeShortcut")}, {"relative", "./claude", "relative path", i18n.T("env.binary.detailRelative")}, - {lichclient.CheckEmpty, "", "", ""}, + {backend.CheckEmpty, "", "", ""}, } for _, c := range cases { - check := lichclient.BinaryCheck{Status: c.status} + check := backend.BinaryCheck{Status: c.status} if got := binCheckLabel(check, c.bin); got != c.label { t.Errorf("%s %q label = %q, want %q", c.status, c.bin, got, c.label) } @@ -150,22 +150,22 @@ func TestBinaryVerdictWordsFollowWhatWasAskedFor(t *testing.T) { } func TestPlanSummaryFitsAListRow(t *testing.T) { - ok := lichclient.QuotaPlan{Status: "ok", Windows: []lichclient.QuotaWindow{ + ok := backend.QuotaPlan{Status: "ok", Windows: []backend.QuotaWindow{ {Label: "Weekly", Seconds: 7 * day, Percent: 20}, {Label: "Session", Seconds: 5 * hour, Percent: 62}, }} cases := []struct { name string - plan lichclient.QuotaPlan + plan backend.QuotaPlan found bool want string }{ - {"no reading", lichclient.QuotaPlan{}, false, ""}, + {"no reading", backend.QuotaPlan{}, false, ""}, {"the hottest window", ok, true, "62% of the 5h window"}, - {"a window with no length", lichclient.QuotaPlan{Status: "ok", Windows: []lichclient.QuotaWindow{{Percent: 7}}}, true, "7% used"}, - {"signed out is words", lichclient.QuotaPlan{Status: "signed-out"}, true, "Signed out"}, - {"an error says nothing", lichclient.QuotaPlan{Status: "error"}, true, ""}, - {"no windows says nothing", lichclient.QuotaPlan{Status: "ok"}, true, ""}, + {"a window with no length", backend.QuotaPlan{Status: "ok", Windows: []backend.QuotaWindow{{Percent: 7}}}, true, "7% used"}, + {"signed out is words", backend.QuotaPlan{Status: "signed-out"}, true, "Signed out"}, + {"an error says nothing", backend.QuotaPlan{Status: "error"}, true, ""}, + {"no windows says nothing", backend.QuotaPlan{Status: "ok"}, true, ""}, } for _, c := range cases { if got := planSummary(c.plan, c.found); got != c.want { @@ -209,7 +209,7 @@ func TestRestoreChoiceIsWhatTheGateUsesNext(t *testing.T) { if len(restoreRungs) != len(want) { t.Fatalf("%d rungs for %d answers the gate has", len(restoreRungs), len(want)) } - session := lichclient.Session{ID: "s", Kind: "codex", ProviderSessionID: "thread"} + session := backend.Session{ID: "s", Kind: "codex", ProviderSessionID: "thread"} for _, rung := range restoreRungs { if restoreChoice(rung.value) != rung.value { t.Errorf("%q is not a value the gate reads back as itself", rung.value) @@ -224,7 +224,7 @@ func TestRestoreChoiceIsWhatTheGateUsesNext(t *testing.T) { func TestThePromptsAlwaysIsASettingsChoice(t *testing.T) { probe := &storeProbe{store: map[string]string{}} g := &spawnGate{probe: probe, invalidate: func() {}, notify: func(ui.Toast) {}} - g.asking = &lichclient.Session{ID: "s", Kind: "codex", ProviderSessionID: "thread"} + g.asking = &backend.Session{ID: "s", Kind: "codex", ProviderSessionID: "thread"} g.answer(context.Background(), restoreResume, true) stored := probe.store[restoreKey("codex")] if !slices.ContainsFunc(restoreRungs, func(r ladderRung) bool { return r.value == stored }) { @@ -357,7 +357,7 @@ func TestOnlyTheLastOfABurstOfWritesIsSent(t *testing.T) { // The machine this was built for: three codex installs on $PATH, the first of // them the one a session spawns. func TestOthersNamesTheVersionsASessionDoesNotRun(t *testing.T) { - v := binVersions{version: "0.162.1", onPath: []lichclient.ProviderBinary{ + v := binVersions{version: "0.162.1", onPath: []backend.ProviderBinary{ {Path: "/home/u/.local/bin/codex", Version: "0.162.1"}, {Path: "/home/u/.local/share/mise/shims/codex", Version: "0.144.5"}, {Path: "/opt/codex-copy", Version: "0.162.1"}, @@ -374,7 +374,7 @@ func TestOthersNamesTheVersionsASessionDoesNotRun(t *testing.T) { } func TestOneBinaryOnPathSaysNothing(t *testing.T) { - v := binVersions{version: "2.1.299", onPath: []lichclient.ProviderBinary{{Path: "/usr/bin/claude", Version: "2.1.299"}}} + v := binVersions{version: "2.1.299", onPath: []backend.ProviderBinary{{Path: "/usr/bin/claude", Version: "2.1.299"}}} if got := alsoOnPathLine(v.others("/usr/bin/claude")); got != "" { t.Errorf("line = %q, want none", got) } @@ -386,7 +386,7 @@ func TestAConfiguredBinaryIsComparedWithEveryoneOnPath(t *testing.T) { if scope != binScopeGlobal || configured != "/opt/codex" || asked != "/opt/codex" { t.Fatalf("spawnedBin = %q %q %q", scope, configured, asked) } - v := binVersions{version: "0.150.0", onPath: []lichclient.ProviderBinary{{Path: "/usr/bin/codex", Version: "0.147.0"}}} + v := binVersions{version: "0.150.0", onPath: []backend.ProviderBinary{{Path: "/usr/bin/codex", Version: "0.147.0"}}} if got := v.others("/opt/codex"); len(got) != 1 { t.Errorf("others = %+v, want /usr/bin/codex", got) } diff --git a/native/cmd/lich-native/settings_providers_version.go b/native/cmd/lich-native/settings_providers_version.go index bd2eaac76..8c38d5955 100644 --- a/native/cmd/lich-native/settings_providers_version.go +++ b/native/cmd/lich-native/settings_providers_version.go @@ -4,8 +4,8 @@ import ( "log" "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // binVersions is what one binary reports and every binary $PATH holds for its @@ -13,7 +13,7 @@ import ( // CLI that prints no version. type binVersions struct { version string - onPath []lichclient.ProviderBinary + onPath []backend.ProviderBinary } // versionsOf is what asked reports and what else $PATH holds for kind, ok @@ -37,8 +37,8 @@ func (p *providerSettings) versionsOf(s *settingsScreen, kind, asked string) (bi // report a different version from it: the ones a user who meant one of them // would be surprised by. A copy of the same version is no surprise, and one // that printed no version cannot be said to differ. -func (v binVersions) others(spawned string) []lichclient.ProviderBinary { - var out []lichclient.ProviderBinary +func (v binVersions) others(spawned string) []backend.ProviderBinary { + var out []backend.ProviderBinary for _, b := range v.onPath { if b.Path == spawned || b.Version == "" || b.Version == v.version { continue @@ -49,7 +49,7 @@ func (v binVersions) others(spawned string) []lichclient.ProviderBinary { } // alsoOnPathLine is the sentence naming others, "" when there are none. -func alsoOnPathLine(others []lichclient.ProviderBinary) string { +func alsoOnPathLine(others []backend.ProviderBinary) string { if len(others) == 0 { return "" } diff --git a/native/cmd/lich-native/settings_providers_view.go b/native/cmd/lich-native/settings_providers_view.go index 072293445..9756db068 100644 --- a/native/cmd/lich-native/settings_providers_view.go +++ b/native/cmd/lich-native/settings_providers_view.go @@ -10,8 +10,8 @@ import ( "gioui.org/op" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -209,7 +209,7 @@ func (s *settingsScreen) providerListRow(gtx C, th *ui.Theme, values scopedValue } st.toggle.Value = p.enabled var versions binVersions - var spawned lichclient.BinaryCheck + var spawned backend.BinaryCheck if p.installed { project, global := s.binLayers(values, m, p.id) _, _, asked := spawnedBin(project, global, p.binary) diff --git a/native/cmd/lich-native/settings_sandbox.go b/native/cmd/lich-native/settings_sandbox.go index f0f7fe306..933d275ef 100644 --- a/native/cmd/lich-native/settings_sandbox.go +++ b/native/cmd/lich-native/settings_sandbox.go @@ -9,8 +9,8 @@ import ( "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -188,12 +188,13 @@ func (s *settingsScreen) loadSandbox() { }() } -func readSandboxMachine(ctx context.Context, c *lichclient.Client) (sandboxMachine, error) { +func readSandboxMachine(ctx context.Context, c *backend.Client) (sandboxMachine, error) { m := sandboxMachine{loaded: true, globals: map[string]string{}} - if err := c.Call(ctx, "system.SandboxBackend", &m.backend); err != nil { + var err error + if m.backend, err = c.SandboxBackend(ctx); err != nil { return m, err } - if err := c.Call(ctx, "system.SSHAgentKeys", &m.agentKeys); err != nil { + if m.agentKeys, err = c.SSHAgentKeys(ctx); err != nil { return m, err } keys := []string{sshAgentKey, ghTokenKey} diff --git a/native/cmd/lich-native/settings_scope.go b/native/cmd/lich-native/settings_scope.go index e7462a306..aca1daaaf 100644 --- a/native/cmd/lich-native/settings_scope.go +++ b/native/cmd/lich-native/settings_scope.go @@ -6,7 +6,7 @@ import ( "image/color" "maps" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // settingsProject is an open project as the scope selector lists it. @@ -159,7 +159,7 @@ func scopedKeys() []string { // provider default and profiles, which the provider book reads. There is no // RPC that lists a project's settings, so it is one GetSetting per pair; the // keys are a handful and the projects are the open tabs. -func loadScopedValues(ctx context.Context, c *lichclient.Client, projects []settingsProject) (scopedValues, error) { +func loadScopedValues(ctx context.Context, c *backend.Client, projects []settingsProject) (scopedValues, error) { values := scopedValues{} for _, key := range scopedKeys() { if key == providerDefaultKey || isProfileKey(key) { diff --git a/native/cmd/lich-native/settings_theme.go b/native/cmd/lich-native/settings_theme.go index e589f96bf..7f9a8c35e 100644 --- a/native/cmd/lich-native/settings_theme.go +++ b/native/cmd/lich-native/settings_theme.go @@ -16,8 +16,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -59,7 +59,7 @@ const themeStripRows = 2 // and the import card open. type themePicker struct { ctx context.Context - client *lichclient.Client + client *backend.Client look *appearance toaster *ui.Toaster root *ui.Root @@ -78,7 +78,7 @@ type themePicker struct { tasks themeTasks } -func newThemePicker(ctx context.Context, c *lichclient.Client, look *appearance, toaster *ui.Toaster, root *ui.Root) *themePicker { +func newThemePicker(ctx context.Context, c *backend.Client, look *appearance, toaster *ui.Toaster, root *ui.Root) *themePicker { p := &themePicker{ctx: ctx, client: c, look: look, toaster: toaster, root: root, cards: map[string]*themeCard{}, brokenRemove: map[string]*brokenRowState{}} p.strip.Axis = layout.Horizontal @@ -284,7 +284,7 @@ func pairCaption(scheme string) string { // brokenList is BrokenThemeList: a theme with no colors to preview is a row // under the strip, its reason written out. -func (p *themePicker) brokenList(gtx C, th *ui.Theme, broken []lichclient.BrokenTheme) D { +func (p *themePicker) brokenList(gtx C, th *ui.Theme, broken []backend.BrokenTheme) D { if len(broken) == 0 { return D{} } @@ -311,7 +311,7 @@ func (p *themePicker) brokenList(gtx C, th *ui.Theme, broken []lichclient.Broken // brokenRow is one grid row: the id, the reason, and Remove, the row's // hover fill reaching px-2 past the list on both sides (-mx-2 px-2). -func (p *themePicker) brokenRow(gtx C, th *ui.Theme, b lichclient.BrokenTheme, row *brokenRowState) D { +func (p *themePicker) brokenRow(gtx C, th *ui.Theme, b backend.BrokenTheme, row *brokenRowState) D { if row.remove.Clicked(gtx) { p.askRemove(b.ID, b.ID) } diff --git a/native/cmd/lich-native/settings_theme_actions.go b/native/cmd/lich-native/settings_theme_actions.go index 8786d0324..b80f87bdf 100644 --- a/native/cmd/lich-native/settings_theme_actions.go +++ b/native/cmd/lich-native/settings_theme_actions.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -57,7 +57,7 @@ func (p *themePicker) fail(title string) { // failedWith is the {error} argument of a failure message, in the backend's // own words. -func failedWith(err error) i18n.Args { return i18n.Args{"error": lichclient.ErrorText(err)} } +func failedWith(err error) i18n.Args { return i18n.Args{"error": backend.ErrorText(err)} } // reloadThemes rereads the list after a change, so the strip and the window // follow it at once. @@ -213,7 +213,7 @@ func (u *window) showThemePicker(mode string) error { u.settings.show("", nil) p := u.settings.theme p.open = true - var custom []lichclient.Theme + var custom []backend.Theme for _, e := range u.appearance.view().themes { if e.def.Origin == themeOriginCustom { custom = append(custom, e.def) diff --git a/native/cmd/lich-native/settings_updates.go b/native/cmd/lich-native/settings_updates.go index 2667eb4ea..542cbb3fd 100644 --- a/native/cmd/lich-native/settings_updates.go +++ b/native/cmd/lich-native/settings_updates.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -26,9 +26,9 @@ type updatesSettings struct { mu sync.Mutex loaded bool - notes *lichclient.PatchNotes + notes *backend.PatchNotes startupNotes bool // startupPatchNotes found a release to show - plugins []lichclient.PluginStatus + plugins []backend.PluginStatus pluginsFailed bool busy bool // a plugin action or check is in flight checked bool // the user asked for a plugin check since Settings opened @@ -86,7 +86,7 @@ func (u *updatesSettings) version() string { return "v" + u.notes.Version } -func (u *updatesSettings) patchNotes() *lichclient.PatchNotes { +func (u *updatesSettings) patchNotes() *backend.PatchNotes { u.mu.Lock() defer u.mu.Unlock() return u.notes @@ -94,7 +94,7 @@ func (u *updatesSettings) patchNotes() *lichclient.PatchNotes { // hasPatchNotes is `notes?.groups`: a section the changelog does not have is null, // and an empty one still opens. -func hasPatchNotes(n *lichclient.PatchNotes) bool { return n != nil && n.Groups != nil } +func hasPatchNotes(n *backend.PatchNotes) bool { return n != nil && n.Groups != nil } // updatesRow draws the Updates rows, false for any other key. func (s *settingsScreen) updatesRow(gtx C, th *ui.Theme, e settingEntry, r settingRow, first bool) (D, bool) { diff --git a/native/cmd/lich-native/settings_updates_notes.go b/native/cmd/lich-native/settings_updates_notes.go index f6a1560ff..450cd1fe9 100644 --- a/native/cmd/lich-native/settings_updates_notes.go +++ b/native/cmd/lich-native/settings_updates_notes.go @@ -14,8 +14,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -127,7 +127,7 @@ func splitLeadIn(item string) (headline, body string) { // layoutHighlights makes the first important block the headline and every // other block a callout under it. -func layoutHighlights(highlights []lichclient.PatchNotesHighlight) (lead *lichclient.PatchNotesHighlight, callouts []lichclient.PatchNotesHighlight) { +func layoutHighlights(highlights []backend.PatchNotesHighlight) (lead *backend.PatchNotesHighlight, callouts []backend.PatchNotesHighlight) { for i := range highlights { if lead == nil && highlights[i].Kind == "important" { lead = &highlights[i] @@ -301,7 +301,7 @@ func (v *patchNotesView) changelogLink(gtx C, th *ui.Theme) D { // body is the release: the counts, the headline, the callouts and the groups, // in a scroll area capped at a share of the panel. -func (v *patchNotesView) body(gtx C, th *ui.Theme, notes *lichclient.PatchNotes) D { +func (v *patchNotesView) body(gtx C, th *ui.Theme, notes *backend.PatchNotes) D { var rows []layout.Widget lead, callouts := layoutHighlights(notes.Highlights) if lead != nil { @@ -322,7 +322,7 @@ func (v *patchNotesView) body(gtx C, th *ui.Theme, notes *lichclient.PatchNotes) } // notesCounts is the line of "3 added" counts the header ends on. -func notesCounts(groups []lichclient.PatchNotesGroup) string { +func notesCounts(groups []backend.PatchNotesGroup) string { parts := make([]string, len(groups)) for i, g := range groups { parts[i] = notesGroupCount(g.Label, len(g.Items)) @@ -337,7 +337,7 @@ func notesDotOf(gtx C, c color.NRGBA, size unit.Dp) D { } // notesLead is the headline: the largest type the app uses and nothing else. -func notesLead(th *ui.Theme, h lichclient.PatchNotesHighlight) layout.Widget { +func notesLead(th *ui.Theme, h backend.PatchNotesHighlight) layout.Widget { headline, body := splitLeadIn(h.Text) head := richText(th, headline, richStyle{size: notesLeadSize, leading: notesLeadSize * 5 / 4, ink: th.Foreground, weight: font.SemiBold}) children := []layout.FlexChild{layout.Rigid(cappedBy(head, notesLeadSize, notesLeadCh))} @@ -357,7 +357,7 @@ func notesLead(th *ui.Theme, h lichclient.PatchNotesHighlight) layout.Widget { // notesCallout is a block under the headline: a warning paints in the // destructive tint, a note sits on the accent fill. -func notesCallout(th *ui.Theme, h lichclient.PatchNotesHighlight) layout.Widget { +func notesCallout(th *ui.Theme, h backend.PatchNotesHighlight) layout.Widget { warning := h.Kind == "warning" icon, iconInk, fill := icNotesNote, th.MutedForeground, th.Over(ui.Alpha(th.Accent, 0.6)) if warning { diff --git a/native/cmd/lich-native/settings_updates_plugin.go b/native/cmd/lich-native/settings_updates_plugin.go index 310d26b2f..c259a25da 100644 --- a/native/cmd/lich-native/settings_updates_plugin.go +++ b/native/cmd/lich-native/settings_updates_plugin.go @@ -8,8 +8,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -34,7 +34,7 @@ type pluginControls struct { } // pluginVersionText is what a row says about the plugin in that CLI. -func pluginVersionText(st lichclient.PluginStatus) string { +func pluginVersionText(st backend.PluginStatus) string { switch { case !st.Available: return i18n.T("settings.pluginSetting.cliMissing") @@ -48,7 +48,7 @@ func pluginVersionText(st lichclient.PluginStatus) string { // pluginAction is the button a row offers, none when there is nothing to // close. An update is an outline button; an install is the surface's primary. -func pluginAction(st lichclient.PluginStatus) (label string, variant ui.Variant, ok bool) { +func pluginAction(st backend.PluginStatus) (label string, variant ui.Variant, ok bool) { switch { case st.Available && !st.Installed: return i18n.T("settings.pluginSetting.install"), ui.VariantDefault, true @@ -60,15 +60,15 @@ func pluginAction(st lichclient.PluginStatus) (label string, variant ui.Variant, return "", ui.VariantDefault, false } -func outdatedPlugins(statuses []lichclient.PluginStatus) []lichclient.PluginStatus { - return slices.DeleteFunc(slices.Clone(statuses), func(st lichclient.PluginStatus) bool { +func outdatedPlugins(statuses []backend.PluginStatus) []backend.PluginStatus { + return slices.DeleteFunc(slices.Clone(statuses), func(st backend.PluginStatus) bool { return !st.Installed || !st.UpdateAvailable }) } // readPlugins asks for the rows again and files the answer, nil when the read // failed; the last good rows stay on screen beside the failure. -func (u *updatesSettings) readPlugins(s *settingsScreen) []lichclient.PluginStatus { +func (u *updatesSettings) readPlugins(s *settingsScreen) []backend.PluginStatus { statuses, err := s.client.PluginStatuses(s.ctx) u.mu.Lock() u.pluginsFailed = err != nil @@ -97,7 +97,7 @@ func (u *updatesSettings) act(s *settingsScreen, provider string) { u.setBusy(s, true) defer u.setBusy(s, false) fresh := u.readPlugins(s) - i := slices.IndexFunc(fresh, func(st lichclient.PluginStatus) bool { return st.Provider == provider }) + i := slices.IndexFunc(fresh, func(st backend.PluginStatus) bool { return st.Provider == provider }) if i < 0 { return } @@ -142,7 +142,7 @@ func (u *updatesSettings) check(s *settingsScreen) { // success so the next visit shows what it changed. func (u *updatesSettings) run(s *settingsScreen, call func() error, done, failed string) { if err := call(); err != nil { - s.theme.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T(failed, i18n.Args{"error": lichclient.ErrorText(err)})}) + s.theme.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T(failed, i18n.Args{"error": backend.ErrorText(err)})}) return } s.theme.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T(done)}) @@ -198,7 +198,7 @@ func pluginOutcome(checked, failed bool) string { // row is one provider CLI: its mark and name, what it has installed, and the // action that closes the gap. -func (c *pluginControls) row(gtx C, th *ui.Theme, st lichclient.PluginStatus, busy bool) D { +func (c *pluginControls) row(gtx C, th *ui.Theme, st backend.PluginStatus, busy bool) D { children := []layout.FlexChild{ layout.Rigid(func(gtx C) D { return providerIcon(st.Provider).Layout(gtx, unit.Dp(pluginProviderIcon), th.Foreground) @@ -256,7 +256,7 @@ func (c *pluginControls) footer(gtx C, th *ui.Theme, offerAll, busy bool, outcom // pluginProfilesFailed raises a toast per profile a pass could not bring to // Personal's lich plugin; a pass with none says nothing, as Personal's own // update already did. -func (u *window) pluginProfilesFailed(pass lichclient.PluginProfiles) { +func (u *window) pluginProfilesFailed(pass backend.PluginProfiles) { for _, f := range pass.Failures { u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("settings.settingsUpdatesPlugin.profileFailed", i18n.Args{"pass": pass.Name, "profile": f.Name, "error": f.Error})}) } diff --git a/native/cmd/lich-native/settings_updates_test.go b/native/cmd/lich-native/settings_updates_test.go index 1784ba01c..31a266825 100644 --- a/native/cmd/lich-native/settings_updates_test.go +++ b/native/cmd/lich-native/settings_updates_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -23,14 +23,14 @@ func TestSplitLeadInTakesTheBoldSentence(t *testing.T) { } func TestLayoutHighlightsDemotesASecondImportant(t *testing.T) { - first := lichclient.PatchNotesHighlight{Kind: "important", Text: "one"} - note := lichclient.PatchNotesHighlight{Kind: "note", Text: "n"} - second := lichclient.PatchNotesHighlight{Kind: "important", Text: "two"} - lead, callouts := layoutHighlights([]lichclient.PatchNotesHighlight{note, first, second}) + first := backend.PatchNotesHighlight{Kind: "important", Text: "one"} + note := backend.PatchNotesHighlight{Kind: "note", Text: "n"} + second := backend.PatchNotesHighlight{Kind: "important", Text: "two"} + lead, callouts := layoutHighlights([]backend.PatchNotesHighlight{note, first, second}) if lead == nil || lead.Text != "one" { t.Fatalf("lead = %v, want the first important", lead) } - if want := []lichclient.PatchNotesHighlight{note, second}; !reflect.DeepEqual(callouts, want) { + if want := []backend.PatchNotesHighlight{note, second}; !reflect.DeepEqual(callouts, want) { t.Fatalf("callouts = %v, want %v", callouts, want) } if lead, callouts := layoutHighlights(nil); lead != nil || callouts != nil { @@ -54,18 +54,18 @@ func TestInlineWordsKeepsPunctuationWithItsCodeSpan(t *testing.T) { } func TestPluginRowsSayWhatTheMachineHas(t *testing.T) { - base := lichclient.PluginStatus{Provider: "claude", Name: "Claude Code", Available: true, Compatible: true, LatestVersion: "0.9.0"} + base := backend.PluginStatus{Provider: "claude", Name: "Claude Code", Available: true, Compatible: true, LatestVersion: "0.9.0"} installed := base installed.Installed, installed.InstalledVersion = true, "0.9.0" outdated := installed outdated.InstalledVersion, outdated.UpdateAvailable = "0.8.0", true unsupported := outdated unsupported.InstalledVersion, unsupported.Compatible = "0.14.0", false - missing := lichclient.PluginStatus{Provider: "codex"} + missing := backend.PluginStatus{Provider: "codex"} for _, tc := range []struct { name string - st lichclient.PluginStatus + st backend.PluginStatus text string action string variant ui.Variant @@ -87,7 +87,7 @@ func TestPluginRowsSayWhatTheMachineHas(t *testing.T) { } func TestOutdatedPluginsSkipsWhatIsNotInstalled(t *testing.T) { - statuses := []lichclient.PluginStatus{ + statuses := []backend.PluginStatus{ {Provider: "claude", Installed: true, UpdateAvailable: true}, {Provider: "codex", Installed: true, UpdateAvailable: true, Compatible: false}, {Provider: "cursor", Installed: true}, diff --git a/native/cmd/lich-native/settings_vcs.go b/native/cmd/lich-native/settings_vcs.go index 63bc18986..79e1eec43 100644 --- a/native/cmd/lich-native/settings_vcs.go +++ b/native/cmd/lich-native/settings_vcs.go @@ -7,8 +7,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -54,10 +54,10 @@ type vcsSettings struct { onAccount func() mu sync.Mutex - checks map[string]lichclient.BinaryCheck + checks map[string]backend.BinaryCheck accounts []string // nil until gh answers; empty is signed in to none accountsErr string - identities askOnce[lichclient.CommitIdentity] // by path + identities askOnce[backend.CommitIdentity] // by path loaded bool checking bool recheckErr string @@ -101,7 +101,7 @@ func (v *vcsSettings) verifyTools(s *settingsScreen) { } v.mu.Lock() if v.checks == nil { - v.checks = map[string]lichclient.BinaryCheck{} + v.checks = map[string]backend.BinaryCheck{} } v.checks[t.bin] = c v.mu.Unlock() @@ -114,7 +114,7 @@ func (v *vcsSettings) listAccounts(s *settingsScreen) { v.mu.Lock() v.accountsErr = "" if err != nil { - v.accountsErr = lichclient.ErrorText(err) + v.accountsErr = backend.ErrorText(err) } else { v.accounts = accounts } @@ -123,7 +123,7 @@ func (v *vcsSettings) listAccounts(s *settingsScreen) { } // check is bin's verdict, ok false until it answered. -func (v *vcsSettings) check(bin string) (lichclient.BinaryCheck, bool) { +func (v *vcsSettings) check(bin string) (backend.BinaryCheck, bool) { v.mu.Lock() defer v.mu.Unlock() c, ok := v.checks[bin] diff --git a/native/cmd/lich-native/settings_vcs_account.go b/native/cmd/lich-native/settings_vcs_account.go index 303954763..f296a9f02 100644 --- a/native/cmd/lich-native/settings_vcs_account.go +++ b/native/cmd/lich-native/settings_vcs_account.go @@ -4,8 +4,8 @@ import ( "slices" "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) const defaultGitHubHost = "github.com" @@ -70,7 +70,7 @@ func accountOptions(accounts []string, stored string) []string { // identityLine is the lead, the email in angle brackets ("" with no identity), and the note. type identityLine struct{ lead, email, note string } -func identityLineOf(id lichclient.CommitIdentity) identityLine { +func identityLineOf(id backend.CommitIdentity) identityLine { if id.Email == "" { return identityLine{lead: i18n.T("env.commitIdentity.noneLead"), note: i18n.T("env.commitIdentity.noneNote")} } diff --git a/native/cmd/lich-native/settings_vcs_picker.go b/native/cmd/lich-native/settings_vcs_picker.go index 950f8ca72..5873eca3c 100644 --- a/native/cmd/lich-native/settings_vcs_picker.go +++ b/native/cmd/lich-native/settings_vcs_picker.go @@ -10,8 +10,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -74,8 +74,8 @@ func (s *settingsScreen) scopeName() string { // identity is path's commit identity, read once per visit; ok false until // git answered. -func (v *vcsSettings) identity(s *settingsScreen, path string) (lichclient.CommitIdentity, bool) { - return v.identities.get(&v.mu, path, s.invalidate, func() (lichclient.CommitIdentity, bool) { +func (v *vcsSettings) identity(s *settingsScreen, path string) (backend.CommitIdentity, bool) { + return v.identities.get(&v.mu, path, s.invalidate, func() (backend.CommitIdentity, bool) { id, err := s.client.CommitIdentity(s.ctx, path) if err != nil { log.Printf("settings: commit identity of %s: %v", path, err) diff --git a/native/cmd/lich-native/settings_vcs_test.go b/native/cmd/lich-native/settings_vcs_test.go index 1c7135ed6..dfb6af56b 100644 --- a/native/cmd/lich-native/settings_vcs_test.go +++ b/native/cmd/lich-native/settings_vcs_test.go @@ -4,7 +4,7 @@ import ( "slices" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestUpgradeAccountOnlyWithoutGuessing(t *testing.T) { @@ -58,12 +58,12 @@ func TestAccountOptionsKeepAStaleStoredAccount(t *testing.T) { func TestIdentityLineSaysWhereTheEmailComesFrom(t *testing.T) { cases := []struct { - id lichclient.CommitIdentity + id backend.CommitIdentity want identityLine }{ - {lichclient.CommitIdentity{}, identityLine{"No git identity in this checkout.", "", "Commits will be refused until user.email is set."}}, - {lichclient.CommitIdentity{Name: "Ana", Email: "a@x.io"}, identityLine{"Commits land as Ana", "", "— git user.email, not this account."}}, - {lichclient.CommitIdentity{Email: "a@x.io", Local: true}, identityLine{"Commits land as", "", "— set in this repository, overriding your global one."}}, + {backend.CommitIdentity{}, identityLine{"No git identity in this checkout.", "", "Commits will be refused until user.email is set."}}, + {backend.CommitIdentity{Name: "Ana", Email: "a@x.io"}, identityLine{"Commits land as Ana", "", "— git user.email, not this account."}}, + {backend.CommitIdentity{Email: "a@x.io", Local: true}, identityLine{"Commits land as", "", "— set in this repository, overriding your global one."}}, } for _, c := range cases { if got := identityLineOf(c.id); got != c.want { diff --git a/native/cmd/lich-native/sidebar_actions.go b/native/cmd/lich-native/sidebar_actions.go index 6e6bc30cc..5868eb855 100644 --- a/native/cmd/lich-native/sidebar_actions.go +++ b/native/cmd/lich-native/sidebar_actions.go @@ -5,7 +5,7 @@ import ( "errors" "slices" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // errNoProject is an action on the sidebar with no project on screen. @@ -65,7 +65,7 @@ func (m *model) commitBlockOrder(ctx context.Context, blockKey string, ids []str // fileSessions files sessions under folder, "" to take them out. Every id is // written: one the project does not hold fails nothing. func (m *model) fileSessions(ctx context.Context, ids []string, folder string) error { - if !m.refileShown(func(s []lichclient.Session) ([]lichclient.Session, bool) { return fileSessions(s, ids, folder) }) { + if !m.refileShown(func(s []backend.Session) ([]backend.Session, bool) { return fileSessions(s, ids, folder) }) { return nil } for _, id := range ids { @@ -83,7 +83,7 @@ func (m *model) renameFolder(ctx context.Context, from, to string) error { if !ok { return errNoProject } - if !m.refileShown(func(s []lichclient.Session) ([]lichclient.Session, bool) { return renameFolder(s, from, to) }) { + if !m.refileShown(func(s []backend.Session) ([]backend.Session, bool) { return renameFolder(s, from, to) }) { return nil } if err := m.moveFold(ctx, p.ID, folderKey(from), to); err != nil { @@ -115,7 +115,7 @@ func (m *model) moveFold(ctx context.Context, projectID, fromKey, to string) err // refileShown applies a folder reducer to the shown project's sessions, // false when it changed nothing. -func (m *model) refileShown(refile func([]lichclient.Session) ([]lichclient.Session, bool)) bool { +func (m *model) refileShown(refile func([]backend.Session) ([]backend.Session, bool)) bool { m.mu.Lock() p := m.shownProject() changed := false @@ -129,7 +129,7 @@ func (m *model) refileShown(refile func([]lichclient.Session) ([]lichclient.Sess return changed } -func (m *model) storedSessions() ([]lichclient.Session, bool) { +func (m *model) storedSessions() ([]backend.Session, bool) { p, ok := m.project() return slices.Clone(p.Sessions), ok } diff --git a/native/cmd/lich-native/sidebar_blocks.go b/native/cmd/lich-native/sidebar_blocks.go index 5f2238b08..53c04bd31 100644 --- a/native/cmd/lich-native/sidebar_blocks.go +++ b/native/cmd/lich-native/sidebar_blocks.go @@ -6,8 +6,8 @@ import ( "strings" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // sidebarFilter is the lens over the shown project's cards. It is never @@ -93,7 +93,7 @@ func isLoneWorktree(blocks []sessionBlock) bool { } // dressBlock reads one block into its view. Callers hold m.mu. -func (m *model) dressBlock(p lichclient.Project, b sessionBlock, header bool, pending []pendingStatus, now time.Time) blockView { +func (m *model) dressBlock(p backend.Project, b sessionBlock, header bool, pending []pendingStatus, now time.Time) blockView { collapsed := m.folds[foldKey(p.ID, b.key)] v := blockView{key: b.key, kind: b.kind, folder: b.folder, path: b.path, collapsed: collapsed, folded: header && collapsed} ids := make([]string, 0, len(b.sessions)) diff --git a/native/cmd/lich-native/sidebar_blocks_test.go b/native/cmd/lich-native/sidebar_blocks_test.go index eaf2adeda..13f068c04 100644 --- a/native/cmd/lich-native/sidebar_blocks_test.go +++ b/native/cmd/lich-native/sidebar_blocks_test.go @@ -6,24 +6,25 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) const sidebarProject = "/home/me/try/repo" // sidebarModel is a model on project "p" at sidebarProject holding stored, the // last session active, backed by a fake settings backend. -func sidebarModel(t *testing.T, stored []lichclient.Session) (*model, *sidebarBackend) { +func sidebarModel(t *testing.T, stored []backend.Session) (*model, *sidebarBackend) { t.Helper() f, c := newSidebarBackend(t) m := newModel(c, func() {}) - m.projects = []lichclient.Project{{ID: "p", Path: sidebarProject, Sessions: stored}} + m.projects = []backend.Project{{ID: "p", Path: sidebarProject, Sessions: stored}} m.activeProject, m.active = "p", stored[len(stored)-1].ID return m, f } func reportStatus(m *model, id, state string) { - send(m, statusEventName, statusEvent{ID: id, State: state}) + send(m, api.SessionStatusEvent.Name, statusEvent{ID: id, State: state}) } func blockKeys(v sidebarView) []string { @@ -40,7 +41,7 @@ func TestSidebarBlocksTitleEachKindAndHideHeadersForOneBlock(t *testing.T) { t.Fatalf("lone block: %+v", v) } stored := pin(filed(ss("s1", "w1@/x/worktrees/p/feat/a", "w2@/x/worktrees/p/feat/a"), "Apps", "w1"), "s1") - stored = with(stored, "w1", func(s *lichclient.Session) { s.Color = "teal" }) + stored = with(stored, "w1", func(s *backend.Session) { s.Color = "teal" }) m, _ = sidebarModel(t, stored) v := m.sidebarBlocks(sidebarFilter{}) titles := []string{} @@ -122,7 +123,7 @@ func TestCommitDragOrderStoresTheWholeList(t *testing.T) { t.Fatal(err) } p, _ := m.project() - if idList(p.Sessions) != "p1,a1,r1" || !slices.Equal(f.calls, []string{`store.ReorderSessions("p",["p1","a1","r1"])`}) { + if idList(p.Sessions) != "p1,a1,r1" || !slices.Equal(f.calls, []string{`store.ReorderSessions({"projectId":"p","ids":["p1","a1","r1"]})`}) { t.Fatalf("order %s, calls %v", idList(p.Sessions), f.calls) } } @@ -149,7 +150,7 @@ func TestFileSessionsChangesTheModelThenTheStore(t *testing.T) { t.Fatal(err) } p, _ := m.project() - want := []string{`store.SetSessionFolder("s1","Apps")`, `store.SetSessionFolder("ghost","Apps")`} + want := []string{`store.SetSessionFolder({"sessionId":"s1","folder":"Apps"})`, `store.SetSessionFolder({"sessionId":"ghost","folder":"Apps"})`} if folderList(p.Sessions) != "Apps," || !slices.Equal(f.calls, want) { t.Fatalf("folders %s, calls %v", folderList(p.Sessions), f.calls) } @@ -172,7 +173,7 @@ func TestRenameFolderMovesTheFoldAndUngroupDropsIt(t *testing.T) { if err := m.ungroupFolder(ctx, "Ops"); err != nil { t.Fatal(err) } - want := []string{`store.RenameFolder("p","Apps","Ops")`, `store.RenameFolder("p","Ops","")`} + want := []string{`store.RenameFolder({"projectId":"p","from":"Apps","to":"Ops"})`, `store.RenameFolder({"projectId":"p","from":"Ops","to":""})`} if got := m.sidebarBlocks(sidebarFilter{}); !slices.Equal(blockKeys(got), []string{rootBlockKey}) || !slices.Equal(f.calls, want) { t.Fatalf("after ungroup: %v, calls %v", blockKeys(got), f.calls) } diff --git a/native/cmd/lich-native/sidebar_closing_test.go b/native/cmd/lich-native/sidebar_closing_test.go index dca9046eb..ef7ec01fc 100644 --- a/native/cmd/lich-native/sidebar_closing_test.go +++ b/native/cmd/lich-native/sidebar_closing_test.go @@ -12,14 +12,14 @@ import ( "gioui.org/op" "gioui.org/text" "gioui.org/unit" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) func views(list ...string) []sessionView { out := make([]sessionView, 0, len(list)) for _, id := range list { - out = append(out, sessionView{Session: lichclient.Session{ID: id}}) + out = append(out, sessionView{Session: backend.Session{ID: id}}) } return out } @@ -185,7 +185,7 @@ func blocksOf(project string, blocks [][]string) sidebarView { for i, ids := range blocks { b := blockView{key: string(rune('A' + i)), kind: blockCheckout} for _, id := range ids { - b.sessions = append(b.sessions, sessionView{Session: lichclient.Session{ID: id}, projectID: project}) + b.sessions = append(b.sessions, sessionView{Session: backend.Session{ID: id}, projectID: project}) } v.blocks = append(v.blocks, b) } diff --git a/native/cmd/lich-native/sidebar_compose_test.go b/native/cmd/lich-native/sidebar_compose_test.go index 2311ac625..146a22c15 100644 --- a/native/cmd/lich-native/sidebar_compose_test.go +++ b/native/cmd/lich-native/sidebar_compose_test.go @@ -5,7 +5,7 @@ import ( "slices" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -96,7 +96,7 @@ func TestBlockOptionsByKindAndFilter(t *testing.T) { func TestTakesCard(t *testing.T) { card := func(folder, path string) sessionView { - return sessionView{Session: lichclient.Session{ID: "c", Folder: folder, Path: path}} + return sessionView{Session: backend.Session{ID: "c", Folder: folder, Path: path}} } apps := blockView{kind: blockFolder, folder: "Apps"} root := blockView{kind: blockCheckout} @@ -211,7 +211,7 @@ func TestWriteSidebarStoresFoldsFilesAndLayout(t *testing.T) { if f.get(sidebarWidthKey+"@") != "20" || f.get(sidebarOpenKey+"@") != "false" { t.Errorf("layout stored as width %q open %q", f.get(sidebarWidthKey+"@"), f.get(sidebarOpenKey+"@")) } - if want := []string{`store.SetSessionFolder("r1","Apps")`}; !slices.Equal(f.calls, want) { + if want := []string{`store.SetSessionFolder({"sessionId":"r1","folder":"Apps"})`}; !slices.Equal(f.calls, want) { t.Errorf("calls %v, want %v", f.calls, want) } if v := m.sidebarBlocks(sidebarFilter{}); !slices.Equal(blockKeys(v), []string{folderKey("Apps"), feat}) || !blockFolded(v, feat) { @@ -222,7 +222,7 @@ func TestWriteSidebarStoresFoldsFilesAndLayout(t *testing.T) { if err := writeSidebar(ctx, m, sidebarWrites{paint: paintRequest{ids: []string{"r1", "f1"}, color: "red"}}, sidebarLayout{}); err != nil { t.Fatal(err) } - if want := []string{`store.SetSessionColor("r1","red")`, `store.SetSessionColor("f1","red")`}; !slices.Equal(f.calls, want) { + if want := []string{`store.SetSessionColor({"sessionId":"r1","color":"red"})`, `store.SetSessionColor({"sessionId":"f1","color":"red"})`}; !slices.Equal(f.calls, want) { t.Errorf("paint calls %v, want %v", f.calls, want) } if v := m.sidebarBlocks(sidebarFilter{}); v.blocks[0].color != "red" { diff --git a/native/cmd/lich-native/sidebar_filter.go b/native/cmd/lich-native/sidebar_filter.go index 651d72d6b..7f9637852 100644 --- a/native/cmd/lich-native/sidebar_filter.go +++ b/native/cmd/lich-native/sidebar_filter.go @@ -3,8 +3,8 @@ package main import ( "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // sessionPhase is the filter chip a session sits under . @@ -67,8 +67,8 @@ func matchesQuery(haystack, query string) bool { // queryHit matches a session's label and checkout, which is what makes typing // a worktree name narrow to that checkout. The branch is left out on purpose: // the checkout path already gives most of it. -func queryHit(query, projectPath string) func(lichclient.Session) bool { - return func(s lichclient.Session) bool { +func queryHit(query, projectPath string) func(backend.Session) bool { + return func(s backend.Session) bool { path := s.Path if path == "" { path = projectPath @@ -82,18 +82,18 @@ func queryHit(query, projectPath string) func(lichclient.Session) bool { // survives whatever the filter says: it is the identity of the terminal // beside it, and the card being answered must not vanish as it turns running. // matched says whether anything matched, which the kept active card hides. -func filterSessions(sessions []lichclient.Session, query, projectPath, activeID string, +func filterSessions(sessions []backend.Session, query, projectPath, activeID string, phases map[sessionPhase]bool, phaseOfSession func(id string) sessionPhase, -) (kept []lichclient.Session, matched bool) { +) (kept []backend.Session, matched bool) { blank := strings.TrimSpace(query) == "" if blank && len(phases) == 0 { return sessions, true } text := queryHit(query, projectPath) - hit := func(s lichclient.Session) bool { + hit := func(s backend.Session) bool { return (blank || text(s)) && (len(phases) == 0 || phases[phaseOfSession(s.ID)]) } - kept = []lichclient.Session{} + kept = []backend.Session{} for _, s := range sessions { h := hit(s) matched = matched || h @@ -106,7 +106,7 @@ func filterSessions(sessions []lichclient.Session, query, projectPath, activeID // phaseCounts is the number beside each chip, counted against the query // alone so a count says what picking that chip would add. -func phaseCounts(sessions []lichclient.Session, query, projectPath string, +func phaseCounts(sessions []backend.Session, query, projectPath string, phaseOfSession func(id string) sessionPhase, ) map[sessionPhase]int { counts := map[sessionPhase]int{phaseWaiting: 0, phaseRunning: 0, phaseUnread: 0, phaseIdle: 0} diff --git a/native/cmd/lich-native/sidebar_filter_test.go b/native/cmd/lich-native/sidebar_filter_test.go index 48952012f..b682ab463 100644 --- a/native/cmd/lich-native/sidebar_filter_test.go +++ b/native/cmd/lich-native/sidebar_filter_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) const ( @@ -12,7 +12,7 @@ const ( filterWorktree = "/home/u/.local/share/lich/worktrees/lich/feat/auth-relay" ) -var filterFixture = []lichclient.Session{ +var filterFixture = []backend.Session{ {ID: "s1", Label: "Relay inbox nudge", Pinned: true}, {ID: "s2", Label: "Patch notes", Pinned: true}, {ID: "s3", Label: "Relay ticket lifecycle"}, diff --git a/native/cmd/lich-native/sidebar_groups.go b/native/cmd/lich-native/sidebar_groups.go index 32032566d..4badfde24 100644 --- a/native/cmd/lich-native/sidebar_groups.go +++ b/native/cmd/lich-native/sidebar_groups.go @@ -3,7 +3,7 @@ package main import ( "slices" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // The block keys. The pinned and root blocks have no path @@ -39,13 +39,13 @@ type sessionBlock struct { kind blockKind folder string path string - sessions []lichclient.Session + sessions []backend.Session } // blockOf orders the checks by precedence: pin, then folder, then the // session's own checkout. A wall (split group) outranks the pin // and would be the first check here once the native window has panes. -func blockOf(s lichclient.Session) blockKind { +func blockOf(s backend.Session) blockKind { switch { case s.Pinned: return blockPinned @@ -64,7 +64,7 @@ func checkoutKey(path string) string { return path } -func blockKeyOf(s lichclient.Session) string { +func blockKeyOf(s backend.Session) string { if blockOf(s) == blockFolder { return folderKey(s.Folder) } @@ -76,9 +76,9 @@ func blockKeyOf(s lichclient.Session) string { // first card sits in the stored list and keeping that order inside. Block // order lives nowhere else, which is what lets a drag move a folder past a // checkout by moving its cards in the flat list. -func sessionBlocks(sessions []lichclient.Session) []sessionBlock { +func sessionBlocks(sessions []backend.Session) []sessionBlock { var blocks []sessionBlock - var pinned []lichclient.Session + var pinned []backend.Session at := map[string]int{} for _, s := range sessions { kind := blockOf(s) @@ -128,7 +128,7 @@ func collapsedMark(pending []pendingStatus, ids []string) string { // checkoutsOf names the checkouts ("" for the project root) a block's cards // belong to, once each in card order: where a folder's + can open a session. -func checkoutsOf(sessions []lichclient.Session) []string { +func checkoutsOf(sessions []backend.Session) []string { var paths []string for _, s := range sessions { if !slices.Contains(paths, s.Path) { @@ -141,7 +141,7 @@ func checkoutsOf(sessions []lichclient.Session) []string { // foldersOf names a project's folders in the order their first card sits, // the order their blocks are drawn in. Read off the unfiltered list: a folder // hidden by a filter is still one the user can file into. -func foldersOf(sessions []lichclient.Session) []string { +func foldersOf(sessions []backend.Session) []string { var names []string for _, s := range sessions { if s.Folder != "" && !slices.Contains(names, s.Folder) { @@ -153,7 +153,7 @@ func foldersOf(sessions []lichclient.Session) []string { // sharedColor is the palette color every card of a folder wears, "" when they // differ or are untinted. -func sharedColor(sessions []lichclient.Session) string { +func sharedColor(sessions []backend.Session) string { if len(sessions) == 0 { return "" } @@ -173,7 +173,7 @@ func sharedColor(sessions []lichclient.Session) string { // leaves every other session where it is. ids not naming that subset exactly // yields a repeat rather than a gap, which applyOrder (and the backend's // caller) rejects whole: that is how a drag raced by a close is dropped. -func reorderSubset(stored []lichclient.Session, ids []string, member func(lichclient.Session) bool) []string { +func reorderSubset(stored []backend.Session, ids []string, member func(backend.Session) bool) []string { queue := slices.Clone(ids) out := make([]string, len(stored)) for i, s := range stored { @@ -204,7 +204,7 @@ func orderBlocks(blocks []sessionBlock, keys []string) []string { // pinned one) out in keys. The slots it may fill are the cards those blocks // drew, so a pinned card keeps its slot and lands back among its neighbors // once unpinned. -func dragOrder(stored []lichclient.Session, blocks []sessionBlock, keys []string) []string { +func dragOrder(stored []backend.Session, blocks []sessionBlock, keys []string) []string { moved := map[string]bool{} var movable []sessionBlock for _, b := range blocks { @@ -216,30 +216,30 @@ func dragOrder(stored []lichclient.Session, blocks []sessionBlock, keys []string moved[s.ID] = true } } - return reorderSubset(stored, orderBlocks(movable, keys), func(s lichclient.Session) bool { return moved[s.ID] }) + return reorderSubset(stored, orderBlocks(movable, keys), func(s backend.Session) bool { return moved[s.ID] }) } // blockOrder is the order a drag inside block commits: ids fill the slots of // the cards that block drew, asked of the block rather than restated as a rule // about pins and paths. -func blockOrder(stored []lichclient.Session, block sessionBlock, ids []string) []string { +func blockOrder(stored []backend.Session, block sessionBlock, ids []string) []string { drawn := map[string]bool{} for _, s := range block.sessions { drawn[s.ID] = true } - return reorderSubset(stored, ids, func(s lichclient.Session) bool { return drawn[s.ID] }) + return reorderSubset(stored, ids, func(s backend.Session) bool { return drawn[s.ID] }) } // applyOrder returns sessions in ids order, false when // ids does not name exactly that set (a close raced the drag). -func applyOrder(sessions []lichclient.Session, ids []string) ([]lichclient.Session, bool) { +func applyOrder(sessions []backend.Session, ids []string) ([]backend.Session, bool) { if len(ids) != len(sessions) { return nil, false } - next := make([]lichclient.Session, 0, len(ids)) + next := make([]backend.Session, 0, len(ids)) for _, id := range ids { - i := slices.IndexFunc(sessions, func(s lichclient.Session) bool { return s.ID == id }) - if i < 0 || slices.ContainsFunc(next, func(s lichclient.Session) bool { return s.ID == id }) { + i := slices.IndexFunc(sessions, func(s backend.Session) bool { return s.ID == id }) + if i < 0 || slices.ContainsFunc(next, func(s backend.Session) bool { return s.ID == id }) { return nil, false } next = append(next, sessions[i]) @@ -249,21 +249,21 @@ func applyOrder(sessions []lichclient.Session, ids []string) ([]lichclient.Sessi // fileSessions files the sessions ids names under folder, or unfiled for "", // the stored order untouched. false when ids names none of them. -func fileSessions(sessions []lichclient.Session, ids []string, folder string) ([]lichclient.Session, bool) { - return refile(sessions, func(s lichclient.Session) bool { return slices.Contains(ids, s.ID) }, folder) +func fileSessions(sessions []backend.Session, ids []string, folder string) ([]backend.Session, bool) { + return refile(sessions, func(s backend.Session) bool { return slices.Contains(ids, s.ID) }, folder) } // renameFolder moves every session filed under from to to, or out of any folder // for "". An empty from is refused, or it would sweep every unfiled session // into a folder nobody asked for. -func renameFolder(sessions []lichclient.Session, from, to string) ([]lichclient.Session, bool) { +func renameFolder(sessions []backend.Session, from, to string) ([]backend.Session, bool) { if from == "" { return sessions, false } - return refile(sessions, func(s lichclient.Session) bool { return s.Folder == from }, to) + return refile(sessions, func(s backend.Session) bool { return s.Folder == from }, to) } -func refile(sessions []lichclient.Session, picked func(lichclient.Session) bool, folder string) ([]lichclient.Session, bool) { +func refile(sessions []backend.Session, picked func(backend.Session) bool, folder string) ([]backend.Session, bool) { if !slices.ContainsFunc(sessions, picked) { return sessions, false } diff --git a/native/cmd/lich-native/sidebar_groups_test.go b/native/cmd/lich-native/sidebar_groups_test.go index 38642d3e2..30bc66bf7 100644 --- a/native/cmd/lich-native/sidebar_groups_test.go +++ b/native/cmd/lich-native/sidebar_groups_test.go @@ -5,15 +5,15 @@ import ( "slices" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // ss builds a stored session list: "id" for the project root, "id@path" for // a worktree; filed and pinned are set by the caller. -func ss(specs ...string) []lichclient.Session { - out := make([]lichclient.Session, 0, len(specs)) +func ss(specs ...string) []backend.Session { + out := make([]backend.Session, 0, len(specs)) for _, spec := range specs { - s := lichclient.Session{ID: spec, Label: spec} + s := backend.Session{ID: spec, Label: spec} for i := range spec { if spec[i] == '@' { s.ID, s.Label, s.Path = spec[:i], spec[:i], spec[i+1:] @@ -24,7 +24,7 @@ func ss(specs ...string) []lichclient.Session { return out } -func with(sessions []lichclient.Session, id string, edit func(*lichclient.Session)) []lichclient.Session { +func with(sessions []backend.Session, id string, edit func(*backend.Session)) []backend.Session { out := slices.Clone(sessions) for i := range out { if out[i].ID == id { @@ -34,13 +34,13 @@ func with(sessions []lichclient.Session, id string, edit func(*lichclient.Sessio return out } -func filed(sessions []lichclient.Session, folder string, ids ...string) []lichclient.Session { +func filed(sessions []backend.Session, folder string, ids ...string) []backend.Session { next, _ := fileSessions(sessions, ids, folder) return next } -func pin(sessions []lichclient.Session, id string) []lichclient.Session { - return with(sessions, id, func(s *lichclient.Session) { s.Pinned = true }) +func pin(sessions []backend.Session, id string) []backend.Session { + return with(sessions, id, func(s *backend.Session) { s.Pinned = true }) } // blockLayout is each block's key with its session ids. @@ -180,32 +180,32 @@ func TestCollapsedMark(t *testing.T) { } func TestSharedColor(t *testing.T) { - stored := with(with(ss("a", "b"), "a", func(s *lichclient.Session) { s.Color = "teal" }), "b", func(s *lichclient.Session) { s.Color = "teal" }) + stored := with(with(ss("a", "b"), "a", func(s *backend.Session) { s.Color = "teal" }), "b", func(s *backend.Session) { s.Color = "teal" }) if got := sharedColor(stored); got != "teal" { t.Fatalf("shared = %q, want teal", got) } - if got := sharedColor(with(stored, "b", func(s *lichclient.Session) { s.Color = "red" })); got != "" { + if got := sharedColor(with(stored, "b", func(s *backend.Session) { s.Color = "red" })); got != "" { t.Fatalf("mixed colors shared %q", got) } - if got := sharedColor(with(stored, "a", func(s *lichclient.Session) { s.Color = "mauve" })); got != "" { + if got := sharedColor(with(stored, "a", func(s *backend.Session) { s.Color = "mauve" })); got != "" { t.Fatalf("a color off the palette shared %q", got) } } func TestReorderSubset(t *testing.T) { stored := pin(pin(ss("a", "p1", "b", "c", "p2"), "p1"), "p2") - unpinned := func(s lichclient.Session) bool { return !s.Pinned } + unpinned := func(s backend.Session) bool { return !s.Pinned } cases := []struct { name string ids []string - member func(lichclient.Session) bool + member func(backend.Session) bool want []string }{ {"the subset moves, the rest stays", []string{"c", "a", "b"}, unpinned, []string{"c", "p1", "a", "b", "p2"}}, - {"the pinned cards swap in place", []string{"p2", "p1"}, func(s lichclient.Session) bool { return s.Pinned }, []string{"a", "p2", "b", "c", "p1"}}, + {"the pinned cards swap in place", []string{"p2", "p1"}, func(s backend.Session) bool { return s.Pinned }, []string{"a", "p2", "b", "c", "p1"}}, // A repeat, not a gap: applyOrder rejects it, dropping a stale drag. {"short ids repeat", []string{"c"}, unpinned, []string{"c", "p1", "b", "c", "p2"}}, - {"no member", nil, func(lichclient.Session) bool { return false }, []string{"a", "p1", "b", "c", "p2"}}, + {"no member", nil, func(backend.Session) bool { return false }, []string{"a", "p1", "b", "c", "p2"}}, } for _, tc := range cases { if got := reorderSubset(stored, tc.ids, tc.member); !slices.Equal(got, tc.want) { @@ -264,15 +264,15 @@ func TestApplyOrderRejectsAnythingButTheExactSet(t *testing.T) { } } -func idList(sessions []lichclient.Session) string { - return joinBy(sessions, func(s lichclient.Session) string { return s.ID }) +func idList(sessions []backend.Session) string { + return joinBy(sessions, func(s backend.Session) string { return s.ID }) } -func folderList(sessions []lichclient.Session) string { - return joinBy(sessions, func(s lichclient.Session) string { return s.Folder }) +func folderList(sessions []backend.Session) string { + return joinBy(sessions, func(s backend.Session) string { return s.Folder }) } -func joinBy(sessions []lichclient.Session, of func(lichclient.Session) string) string { +func joinBy(sessions []backend.Session, of func(backend.Session) string) string { out := "" for i, s := range sessions { if i > 0 { diff --git a/native/cmd/lich-native/sidebar_i18n_test.go b/native/cmd/lich-native/sidebar_i18n_test.go index 6957f9e23..4ef69c30f 100644 --- a/native/cmd/lich-native/sidebar_i18n_test.go +++ b/native/cmd/lich-native/sidebar_i18n_test.go @@ -4,8 +4,8 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) func inLocale(t *testing.T, l i18n.Locale) { @@ -77,8 +77,8 @@ func TestNoMatchNoticeJoinsThePhasesInTheLocale(t *testing.T) { func TestFreshnessLinePluralisesInTheLocale(t *testing.T) { inLocale(t, i18n.PtBR) - one, _ := freshnessLine("main", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", Ahead: 1}, ""), false) - many, _ := freshnessLine("main", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", Ahead: 2}, ""), false) + one, _ := freshnessLine("main", doneCheck(backend.BaseFreshness{Upstream: "origin/main", Ahead: 1}, ""), false) + many, _ := freshnessLine("main", doneCheck(backend.BaseFreshness{Upstream: "origin/main", Ahead: 2}, ""), false) if one != "1 commit não enviado · começa da main local" { t.Errorf("one = %q", one) } diff --git a/native/cmd/lich-native/sidebar_launch_test.go b/native/cmd/lich-native/sidebar_launch_test.go index 619ebc936..d7eb51039 100644 --- a/native/cmd/lich-native/sidebar_launch_test.go +++ b/native/cmd/lich-native/sidebar_launch_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestReadEnabledLeavesOnlyClaudeOnByDefault(t *testing.T) { @@ -71,7 +71,7 @@ func TestFolderLaunchHeading(t *testing.T) { func TestModelLaunchMenuReadsDetectionFlagsAndDefaults(t *testing.T) { f, c := newSidebarBackend(t) - f.detected = []lichclient.DetectedProvider{ + f.detected = []backend.DetectedProvider{ {ID: "claude", Name: "Claude Code", Installed: true}, {ID: "codex", Name: "Codex", Installed: true}, {ID: "crush", Name: "Crush"}, @@ -81,7 +81,7 @@ func TestModelLaunchMenuReadsDetectionFlagsAndDefaults(t *testing.T) { f.settings["provider.default@"] = "crush" f.settings["provider.default@p"] = "codex" m := newModel(c, func() {}) - m.projects = []lichclient.Project{{ID: "p"}} + m.projects = []backend.Project{{ID: "p"}} m.providers.noteProjects(m.projects) if err := m.providers.reload(context.Background()); err != nil { t.Fatal(err) diff --git a/native/cmd/lich-native/sidebar_prefs.go b/native/cmd/lich-native/sidebar_prefs.go index 7c11b14b4..76866ffe6 100644 --- a/native/cmd/lich-native/sidebar_prefs.go +++ b/native/cmd/lich-native/sidebar_prefs.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // The sidebar's backend settings. Width and open state are global; a fold is @@ -58,7 +58,7 @@ func parseBoolSetting(raw string, fallback bool) bool { return fallback } -func loadSidebarLayout(ctx context.Context, c *lichclient.Client) (sidebarLayout, error) { +func loadSidebarLayout(ctx context.Context, c *backend.Client) (sidebarLayout, error) { width, err := c.GetSetting(ctx, sidebarWidthKey, "") if err != nil { return sidebarLayout{}, err @@ -70,22 +70,22 @@ func loadSidebarLayout(ctx context.Context, c *lichclient.Client) (sidebarLayout return sidebarLayout{widthRem: parseSidebarWidth(width), open: parseBoolSetting(open, true)}, nil } -func saveSidebarWidth(ctx context.Context, c *lichclient.Client, rem float64) error { +func saveSidebarWidth(ctx context.Context, c *backend.Client, rem float64) error { return c.SetSetting(ctx, sidebarWidthKey, "", strconv.FormatFloat(clampSidebarWidth(rem), 'f', -1, 64)) } -func saveSidebarOpen(ctx context.Context, c *lichclient.Client, open bool) error { +func saveSidebarOpen(ctx context.Context, c *backend.Client, open bool) error { return c.SetSetting(ctx, sidebarOpenKey, "", strconv.FormatBool(open)) } -func loadCollapsed(ctx context.Context, c *lichclient.Client, projectID, blockKey string) (bool, error) { +func loadCollapsed(ctx context.Context, c *backend.Client, projectID, blockKey string) (bool, error) { raw, err := c.GetSetting(ctx, sidebarCollapsedKey+blockKey, projectID) return parseBoolSetting(raw, false), err } // saveCollapsed writes unfolded as "": the store has no delete, and GetSetting // answers "" for an unset key, so unfolded stays stored by not being stored. -func saveCollapsed(ctx context.Context, c *lichclient.Client, projectID, blockKey string, collapsed bool) error { +func saveCollapsed(ctx context.Context, c *backend.Client, projectID, blockKey string, collapsed bool) error { value := "" if collapsed { value = "true" @@ -96,7 +96,7 @@ func saveCollapsed(ctx context.Context, c *lichclient.Client, projectID, blockKe // moveCollapsed carries a fold from one block key to another, dropping it for // an empty to. A folder is keyed by its name, so without this a rename would // unfold the block under the user and strand the old entry. -func moveCollapsed(ctx context.Context, c *lichclient.Client, projectID, from, to string) (bool, error) { +func moveCollapsed(ctx context.Context, c *backend.Client, projectID, from, to string) (bool, error) { collapsed, err := loadCollapsed(ctx, c, projectID, from) if err != nil { return false, err diff --git a/native/cmd/lich-native/sidebar_prefs_test.go b/native/cmd/lich-native/sidebar_prefs_test.go index f30cc644b..fd1d3c5df 100644 --- a/native/cmd/lich-native/sidebar_prefs_test.go +++ b/native/cmd/lich-native/sidebar_prefs_test.go @@ -12,7 +12,8 @@ import ( "sync" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // sidebarBackend is a backend holding settings by key@scope and answering @@ -21,12 +22,12 @@ import ( type sidebarBackend struct { mu sync.Mutex settings map[string]string - detected []lichclient.DetectedProvider - themes []lichclient.Theme + detected []backend.DetectedProvider + themes []backend.Theme calls []string } -func newSidebarBackend(t *testing.T) (*sidebarBackend, *lichclient.Client) { +func newSidebarBackend(t *testing.T) (*sidebarBackend, *backend.Client) { t.Helper() f := &sidebarBackend{settings: map[string]string{}} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -38,22 +39,20 @@ func newSidebarBackend(t *testing.T) (*sidebarBackend, *lichclient.Client) { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return f, lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return f, backend.New(backend.Runtime{Port: port, Token: "tok"}) } func (f *sidebarBackend) answer(method string, args []json.RawMessage) any { f.mu.Lock() defer f.mu.Unlock() - str := func(i int) string { - var s string - _ = json.Unmarshal(args[i], &s) - return s - } switch method { - case "store.GetSetting": - return f.settings[str(0)+"@"+str(1)] - case "store.SetSetting": - f.settings[str(0)+"@"+str(1)] = str(2) + case "store.GetSetting", "store.SetSetting": + var opts api.SetSettingOptions + _ = json.Unmarshal(args[0], &opts) + if method == "store.GetSetting" { + return f.settings[opts.Key+"@"+opts.ProjectID] + } + f.settings[opts.Key+"@"+opts.ProjectID] = opts.Value return nil case "providers.Detect": return f.detected @@ -64,7 +63,7 @@ func (f *sidebarBackend) answer(method string, args []json.RawMessage) any { if theme == "" { theme = themeSystem } - return lichclient.ThemeSelection{Theme: theme, FollowSystem: f.settings["appearance.followSystem@"] == "true"} + return backend.ThemeSelection{Theme: theme, FollowSystem: f.settings["appearance.followSystem@"] == "true"} case "themes.Select": var opts struct { ID string `json:"id"` @@ -73,9 +72,13 @@ func (f *sidebarBackend) answer(method string, args []json.RawMessage) any { f.settings["appearance.theme@"] = opts.ID return nil case "patchnotes.Current": - return lichclient.PatchNotes{Version: "1.0.0", Groups: []lichclient.PatchNotesGroup{{Label: "Added", Items: []string{str(0)}}}} + var opts api.PatchNotesOptions + _ = json.Unmarshal(args[0], &opts) + return backend.PatchNotes{Version: "1.0.0", Groups: []backend.PatchNotesGroup{{Label: "Added", Items: []string{opts.Locale}}}} case "themes.SetFollowSystem": - f.settings["appearance.followSystem@"] = string(args[0]) + var opts api.FollowSystemOptions + _ = json.Unmarshal(args[0], &opts) + f.settings["appearance.followSystem@"] = strconv.FormatBool(opts.On) return nil } raw := make([]string, len(args)) diff --git a/native/cmd/lich-native/sidebar_profile.go b/native/cmd/lich-native/sidebar_profile.go index 55fe2f6d5..27f235d4f 100644 --- a/native/cmd/lich-native/sidebar_profile.go +++ b/native/cmd/lich-native/sidebar_profile.go @@ -6,8 +6,8 @@ import ( "gioui.org/unit" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -147,7 +147,7 @@ func (u *window) openLoginTerminal(command string) bool { // resumeProfileLine tells a restored card which profile its conversation lives // in, "" for Personal, the one nothing needs saying about. -func (m *model) resumeProfileLine(s lichclient.Session) string { +func (m *model) resumeProfileLine(s backend.Session) string { book := m.profileBook(s.Kind) if book == nil || effectiveProfile(s.Profile) == personalProfile { return "" diff --git a/native/cmd/lich-native/sidebar_profile_footer.go b/native/cmd/lich-native/sidebar_profile_footer.go index 74242291a..1b312857e 100644 --- a/native/cmd/lich-native/sidebar_profile_footer.go +++ b/native/cmd/lich-native/sidebar_profile_footer.go @@ -7,8 +7,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -28,7 +28,7 @@ type profileFooter struct { type profileFocus struct { kind string book *profileBook - project lichclient.Project + project backend.Project choice projectProfile } @@ -51,7 +51,7 @@ func (m *model) focusedProfile() (profileFocus, bool) { return m.profileFocusOf(kind, p) } -func (m *model) profileFocusOf(kind string, p lichclient.Project) (profileFocus, bool) { +func (m *model) profileFocusOf(kind string, p backend.Project) (profileFocus, bool) { book := m.profileBook(kind) if book == nil || len(book.loggedIn()) < 2 { return profileFocus{}, false diff --git a/native/cmd/lich-native/sidebar_profile_footer_test.go b/native/cmd/lich-native/sidebar_profile_footer_test.go index 240df1b73..d84fb6717 100644 --- a/native/cmd/lich-native/sidebar_profile_footer_test.go +++ b/native/cmd/lich-native/sidebar_profile_footer_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // profileFooterRig is a window with Claude and Codex installed, two Claude logins, @@ -16,12 +16,12 @@ func profileFooterRig(t *testing.T) (*sidebarBackend, *model, *settingsScreen) { twoInstalled(f) m, s := launchRig(t, f, c) s.providers.claude.bind(m.profiles, nil) - m.profiles.list = []lichclient.Profile{ + m.profiles.list = []backend.Profile{ {Slug: personalProfile, Name: "Personal", LoggedIn: true}, {Slug: "work", Name: "Work", LoggedIn: true}, } m.mu.Lock() - m.projects = []lichclient.Project{{ID: "p", Name: "lich", Sessions: []lichclient.Session{ + m.projects = []backend.Project{{ID: "p", Name: "lich", Sessions: []backend.Session{ {ID: "c", Kind: claudeKind}, {ID: "x", Kind: codexKind}, {ID: "sh", Kind: "shell"}, }}} m.activeProject, m.active = "p", "c" @@ -91,7 +91,7 @@ func TestAProjectOpenedAfterTheReadBringsItsProfile(t *testing.T) { f.settings["provider.claude.profile@late"] = "work" f.mu.Unlock() m.mu.Lock() - m.projects = append(m.projects, lichclient.Project{ID: "late", Name: "late"}) + m.projects = append(m.projects, backend.Project{ID: "late", Name: "late"}) m.activeProject, m.active = "late", "" fresh := m.providers.noteProjects(m.projects) m.mu.Unlock() diff --git a/native/cmd/lich-native/spawn_gate.go b/native/cmd/lich-native/spawn_gate.go index 650d918a2..6c01d50d2 100644 --- a/native/cmd/lich-native/spawn_gate.go +++ b/native/cmd/lich-native/spawn_gate.go @@ -6,8 +6,8 @@ import ( "slices" "sync" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -32,7 +32,7 @@ const ( // resumableKinds are the providers whose resume lich wires. var resumableKinds = []string{"claude", "codex", "antigravity", "omp", "opencode", "crush", "cursor", "kiro"} -// spawnProbe is the backend reads the gate asks, *lichclient.Client in the +// spawnProbe is the backend reads the gate asks, *backend.Client in the // window and a fake in tests. type spawnProbe interface { Live(ctx context.Context, id string) (bool, error) @@ -56,7 +56,7 @@ func restoreChoice(value string) string { // resumable reports whether s carries a conversation its spawn should ask // about. Shell sessions never do, whatever id a hand-run CLI left on the row. -func resumable(s lichclient.Session) bool { +func resumable(s backend.Session) bool { return s.ProviderSessionID != "" && slices.Contains(resumableKinds, s.Kind) } @@ -65,7 +65,7 @@ func resumable(s lichclient.Session) bool { // workdir check that errors spawns (the PTY reports its own failure), a resume // or choice read that errors asks (losing a live conversation to an answer // nobody gave costs more). -func spawnDecision(ctx context.Context, probe spawnProbe, cwd string, s lichclient.Session) spawnVerdict { +func spawnDecision(ctx context.Context, probe spawnProbe, cwd string, s backend.Session) spawnVerdict { missing, err := probe.WorkdirMissing(ctx, cwd) logGateErr("workdir check", err) if missing && err == nil { @@ -113,7 +113,7 @@ type spawnGate struct { mu sync.Mutex deciding string - asking *lichclient.Session + asking *backend.Session released map[string]string } @@ -174,7 +174,7 @@ func (g *spawnGate) releaseLocked(id, resume string) { } // prompt is the session whose resume prompt is up, nil for none. -func (g *spawnGate) prompt() *lichclient.Session { +func (g *spawnGate) prompt() *backend.Session { g.mu.Lock() defer g.mu.Unlock() return g.asking @@ -201,7 +201,7 @@ func (g *spawnGate) answer(ctx context.Context, choice string, remember bool) { return } if err := g.probe.SetSetting(ctx, restoreKey(s.Kind), "", choice); err != nil { - g.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.host.rememberFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + g.notify(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.host.rememberFailed", i18n.Args{"error": backend.ErrorText(err)})}) } } diff --git a/native/cmd/lich-native/spawn_gate_test.go b/native/cmd/lich-native/spawn_gate_test.go index e2e51c3f2..0d0ffafd7 100644 --- a/native/cmd/lich-native/spawn_gate_test.go +++ b/native/cmd/lich-native/spawn_gate_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -43,19 +43,19 @@ func (f *fakeProbe) SetSetting(_ context.Context, key, _, value string) error { return nil } -var restored = lichclient.Session{ID: "s1", Kind: "claude", Label: "Claude 1", ProviderSessionID: "conv-1"} +var restored = backend.Session{ID: "s1", Kind: "claude", Label: "Claude 1", ProviderSessionID: "conv-1"} func TestSpawnDecision(t *testing.T) { cases := []struct { name string probe fakeProbe - session lichclient.Session + session backend.Session want spawnVerdict }{ {"missing workdir parks", fakeProbe{missing: true, available: true}, restored, spawnPark}, {"workdir check error spawns on", fakeProbe{missing: true, missingErr: errProbe, available: true, choice: "resume"}, restored, spawnResume}, - {"no conversation spawns", fakeProbe{available: true}, lichclient.Session{Kind: "claude"}, spawnPlain}, - {"shell never resumes", fakeProbe{available: true}, lichclient.Session{Kind: "shell", ProviderSessionID: "x"}, spawnPlain}, + {"no conversation spawns", fakeProbe{available: true}, backend.Session{Kind: "claude"}, spawnPlain}, + {"shell never resumes", fakeProbe{available: true}, backend.Session{Kind: "shell", ProviderSessionID: "x"}, spawnPlain}, {"gone conversation spawns fresh", fakeProbe{}, restored, spawnFresh}, {"resume check error asks", fakeProbe{availErr: errProbe}, restored, spawnAsk}, {"choice resume", fakeProbe{available: true, choice: "resume"}, restored, spawnResume}, diff --git a/native/cmd/lich-native/stale_dialogs_test.go b/native/cmd/lich-native/stale_dialogs_test.go index f104f67b5..2fde8551f 100644 --- a/native/cmd/lich-native/stale_dialogs_test.go +++ b/native/cmd/lich-native/stale_dialogs_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -68,14 +68,14 @@ func TestLayingOutDelegatesFromOtherLayoutsAsksInTheFrameAfterTheClick(t *testin func TestViewPatchNotesOpensThemInTheFrameAfterTheClick(t *testing.T) { s, r := settingsFrames(t, sectionUpdates) s.updates.loaded = true - s.updates.notes = &lichclient.PatchNotes{Version: "1.4.0", Groups: []lichclient.PatchNotesGroup{}} + s.updates.notes = &backend.PatchNotes{Version: "1.4.0", Groups: []backend.PatchNotesGroup{}} _, next := r.clickLabel(t, i18n.T("settings.updatesSettings.viewPatchNotes")) if !strings.Contains(next, i18n.T("shell.patchNotesDialog.gotIt")) { t.Errorf("the patch notes never came up:\n%s", next) } } -func pluginGateFrames(t *testing.T, offer ...lichclient.PluginStatus) (*window, *frameRig) { +func pluginGateFrames(t *testing.T, offer ...backend.PluginStatus) (*window, *frameRig) { t.Helper() u, _ := newGateWindow(t) u.plugin.started, u.plugin.offer = true, offer @@ -91,7 +91,7 @@ func pluginGateFrames(t *testing.T, offer ...lichclient.PluginStatus) (*window, func TestPluginGateClosesInTheFrameAfterTheClick(t *testing.T) { for _, button := range []string{"shell.agentPluginGate.notNow", "shell.agentPluginGate.dontAskAgain"} { t.Run(button, func(t *testing.T) { - _, r := pluginGateFrames(t, lichclient.PluginStatus{Provider: "claude", Name: "Claude Code"}) + _, r := pluginGateFrames(t, backend.PluginStatus{Provider: "claude", Name: "Claude Code"}) _, next := r.clickLabel(t, i18n.T(button)) if strings.Contains(r.settle(t), i18n.T(button)) { t.Errorf("the gate stayed up:\n%s", next) @@ -101,7 +101,7 @@ func TestPluginGateClosesInTheFrameAfterTheClick(t *testing.T) { } func TestPluginGateDisablesInstallInTheFrameThatUntickedTheLastCLI(t *testing.T) { - _, r := pluginGateFrames(t, lichclient.PluginStatus{Provider: "claude", Name: "Claude Code"}) + _, r := pluginGateFrames(t, backend.PluginStatus{Provider: "claude", Name: "Claude Code"}) r.settle(t) label := r.at(t, "Claude Code") box := r.smallestClickIn(t, image.Rect(0, int(label.Y)-20, int(label.X), int(label.Y)+20)) diff --git a/native/cmd/lich-native/stale_pulls_test.go b/native/cmd/lich-native/stale_pulls_test.go index 57414cade..0f552836f 100644 --- a/native/cmd/lich-native/stale_pulls_test.go +++ b/native/cmd/lich-native/stale_pulls_test.go @@ -8,8 +8,8 @@ import ( "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -25,9 +25,9 @@ func pullsFrames(t *testing.T) (*window, *frameRig) { stub := &rpcStub{answer: func(method string, _ []json.RawMessage) (any, int) { switch method { case "project.ListPullRequests": - return []lichclient.PRSummary{{Number: 7, Title: "Fix the thing", State: "OPEN"}, {Number: 8, Title: "Draft work", State: "OPEN", IsDraft: true}}, http.StatusOK + return []backend.PRSummary{{Number: 7, Title: "Fix the thing", State: "OPEN"}, {Number: 8, Title: "Draft work", State: "OPEN", IsDraft: true}}, http.StatusOK case "project.PullRequestDetail": - return lichclient.PRDetail{Number: 7, URL: "https://github.com/o/r/pull/7", Title: "Fix the thing", Body: "The body.", State: "OPEN", HeadRefName: "fix", BaseRefName: "main"}, http.StatusOK + return backend.PRDetail{Number: 7, URL: "https://github.com/o/r/pull/7", Title: "Fix the thing", Body: "The body.", State: "OPEN", HeadRefName: "fix", BaseRefName: "main"}, http.StatusOK case "project.PullRequestConversation": return conv, http.StatusOK case "project.PullRequestDiff": @@ -49,7 +49,7 @@ func pullsFrames(t *testing.T) (*window, *frameRig) { } u.pulls.view.files = files u.pulls.view.files.pr = &prFiles{ticks: map[string]*widget.Bool{}} - u.pulls.show(lichclient.Project{ID: "p", Path: "/repo"}, "main", 0) + u.pulls.show(backend.Project{ID: "p", Path: "/repo"}, "main", 0) r.draw = func(gtx C, th *ui.Theme) { u.theme = th u.root.Layout(gtx, func(gtx C) D { diff --git a/native/cmd/lich-native/status.go b/native/cmd/lich-native/status.go index f684cf85c..af6c268c0 100644 --- a/native/cmd/lich-native/status.go +++ b/native/cmd/lich-native/status.go @@ -1,12 +1,11 @@ package main import ( - "encoding/json" "errors" - "fmt" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // The states a card draws an indicator for. Every other reported state (the @@ -20,24 +19,11 @@ const ( statusCompacting = "compacting" ) -const statusEventName = "session-status" - -// exitEventPrefix is internal/terminal's terminal:exit:, published once -// when a session's process is gone. -const exitEventPrefix = "terminal:exit:" - // badgePriority picks a project's one badge: "waiting" blocks the user, "busy" // and "compacting" are still running, "done" is the leftover. var badgePriority = []string{statusWaiting, statusBusy, statusCompacting, statusDone} -// statusEvent is the session-status payload (internal/terminal statusEvent). -type statusEvent struct { - ID string `json:"id"` - State string `json:"state"` - Tool string `json:"tool,omitempty"` - Detail string `json:"detail,omitempty"` - Reason string `json:"reason,omitempty"` -} +type statusEvent = api.SessionStatus // sessionTool is the tool a session's turn is running, in its harness's words. type sessionTool struct { @@ -98,16 +84,16 @@ func (s *statusStore) entryOf(id string) *statusEntry { // apply folds one /events message into the store at now. Events other than // session-status are ignored. changed reports whether anything a card draws // moved; a payload that does not decode or names no session is an error. -func (s *statusStore) apply(ev lichclient.Event, now time.Time) (changed bool, err error) { - if ev.Name != statusEventName { +func (s *statusStore) apply(ev backend.Event, now time.Time) (changed bool, err error) { + if ev.Name != api.SessionStatusEvent.Name { return false, nil } - var p statusEvent - if err := json.Unmarshal(ev.Data, &p); err != nil { - return false, fmt.Errorf("%s: decode: %w", statusEventName, err) + p, err := backend.Decode(ev, api.SessionStatusEvent) + if err != nil { + return false, err } if p.ID == "" { - return false, errors.New(statusEventName + ": payload has no session id") + return false, errors.New(ev.Name + ": payload has no session id") } e := s.entryOf(p.ID) e.heard = true @@ -259,7 +245,7 @@ func (s *statusStore) restoreUnread(ids []string) { // session the stream already spoke for is left alone: that report is newer. A // finished turn is not seeded: whether it was read is the database's // (restoreUnread), which the snapshot does not know. -func (s *statusStore) hydrate(states []lichclient.TurnState) { +func (s *statusStore) hydrate(states []backend.TurnState) { for _, ts := range states { if ts.State == "" { continue diff --git a/native/cmd/lich-native/status_test.go b/native/cmd/lich-native/status_test.go index 63760415f..2b7c87b9e 100644 --- a/native/cmd/lich-native/status_test.go +++ b/native/cmd/lich-native/status_test.go @@ -6,18 +6,19 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) var t0 = time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) -func statusEv(t *testing.T, p statusEvent) lichclient.Event { +func statusEv(t *testing.T, p statusEvent) backend.Event { t.Helper() data, err := json.Marshal(p) if err != nil { t.Fatal(err) } - return lichclient.Event{Name: statusEventName, Data: data} + return backend.Event{Name: api.SessionStatusEvent.Name, Data: data} } // step is one report, landing secs seconds after t0. @@ -209,13 +210,13 @@ func TestStatusStoreAgeClampsBackwardsClock(t *testing.T) { func TestStatusStoreApplyErrors(t *testing.T) { s := newStatusStore() - if changed, err := s.apply(lichclient.Event{Name: "session-cwd", Data: []byte(`{"id":"a"}`)}, t0); changed || err != nil { + if changed, err := s.apply(backend.Event{Name: "session-cwd", Data: []byte(`{"id":"a"}`)}, t0); changed || err != nil { t.Errorf("other event = %v %v, want ignored", changed, err) } - if _, err := s.apply(lichclient.Event{Name: statusEventName, Data: []byte(`{"id":"a","state":1}`)}, t0); err == nil { + if _, err := s.apply(backend.Event{Name: api.SessionStatusEvent.Name, Data: []byte(`{"id":"a","state":1}`)}, t0); err == nil { t.Error("a non-string state decoded") } - if _, err := s.apply(lichclient.Event{Name: statusEventName, Data: []byte(`{"state":"busy"}`)}, t0); err == nil { + if _, err := s.apply(backend.Event{Name: api.SessionStatusEvent.Name, Data: []byte(`{"state":"busy"}`)}, t0); err == nil { t.Error("a payload with no id was accepted") } if s.reported("") { diff --git a/native/cmd/lich-native/stores_events_test.go b/native/cmd/lich-native/stores_events_test.go index 8ed073f1f..2b10f0d71 100644 --- a/native/cmd/lich-native/stores_events_test.go +++ b/native/cmd/lich-native/stores_events_test.go @@ -13,7 +13,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -30,7 +31,7 @@ func (s *rpcStub) calls(method string) int { return s.counts[method] } -func (s *rpcStub) client(t *testing.T) *lichclient.Client { +func (s *rpcStub) client(t *testing.T) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) @@ -50,7 +51,7 @@ func (s *rpcStub) client(t *testing.T) *lichclient.Client { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func failing(message string) (any, int) { @@ -65,7 +66,7 @@ func TestSessionTouchedReadsGitNowAndNudgesTheDiff(t *testing.T) { defer mu.Unlock() switch method { case "project.Diff": - return lichclient.DiffStats{Branch: "main", Head: head}, http.StatusOK + return backend.DiffStats{Branch: "main", Head: head}, http.StatusOK case "project.BaseStatus", "project.PullRequest": return nil, http.StatusOK } @@ -147,7 +148,7 @@ func TestReloadAdoptsTheBackendsActiveCardWhenTheOnScreenOneWasClosed(t *testing func TestShownPathsHoldsAnEmptyProjectsOwn(t *testing.T) { m := newModel(nil, func() {}) - m.projects = []lichclient.Project{{ID: "p", Path: checkout}} + m.projects = []backend.Project{{ID: "p", Path: checkout}} m.activeProject = "p" if paths := m.shownPaths(); len(paths) != 1 || paths[0] != checkout { t.Errorf("polls %q, want the project's own path for the footer", paths) @@ -189,7 +190,7 @@ func TestGitStoreKeepsTheLastReadingThroughATransientFailure(t *testing.T) { if broken { return failing("index.lock exists") } - return lichclient.DiffStats{Branch: "main", Head: "h"}, http.StatusOK + return backend.DiffStats{Branch: "main", Head: "h"}, http.StatusOK case "project.Exists": return exists, http.StatusOK case "project.BaseStatus", "project.PullRequest": @@ -218,7 +219,7 @@ func TestGitStoreKeepsTheLastReadingThroughATransientFailure(t *testing.T) { } func TestAnOpenPullRequestIsAskedAgainOnASlowCadence(t *testing.T) { - forge := &fakeForge{prs: map[string]*lichclient.PullRequest{"c1": {Number: 7}}} + forge := &fakeForge{prs: map[string]*backend.PullRequest{"c1": {Number: 7}}} forge.set("feat", "c1") m := newTestModel(forge.client(t)) prOnCard(t, m, 7) @@ -244,7 +245,7 @@ func TestAPathWithNoPullRequestIsNotAskedAgainByTheCadence(t *testing.T) { stub := &rpcStub{answer: func(method string, _ []json.RawMessage) (any, int) { switch method { case "project.Diff": - return lichclient.DiffStats{Branch: "main", Head: "h"}, http.StatusOK + return backend.DiffStats{Branch: "main", Head: "h"}, http.StatusOK case "project.BaseStatus", "project.PullRequest": return nil, http.StatusOK } @@ -275,27 +276,27 @@ func TestALoginEndingReloadsTheProfilesBeforeTheScreenHearsIt(t *testing.T) { if method != "claudeprofile.Profiles" { return failing("unexpected " + method) } - return []lichclient.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}, + return []backend.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}, {Slug: "work", Name: "Work", LoggedIn: loggedIn}}, http.StatusOK }} m := newModel(stub.client(t), func() {}) if err := m.profiles.reload(context.Background()); err != nil { t.Fatal(err) } - m.quota.seed(profileQuotaKey(claudeProfileProvider, "work"), []lichclient.QuotaPlan{{Provider: claudeKind}}) + m.quota.seed(profileQuotaKey(claudeProfileProvider, "work"), []backend.QuotaPlan{{Provider: claudeKind}}) mu.Lock() loggedIn = true mu.Unlock() heard := make(chan []string, 1) - m.loginProgress = func(string, lichclient.ProfileLogin) { + m.loginProgress = func(string, backend.ProfileLogin) { var slugs []string for _, p := range m.profiles.loggedIn() { slugs = append(slugs, p.Slug) } heard <- slugs } - send(m, lichclient.ClaudeProfileLoginEvent, lichclient.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) + send(m, api.ClaudeProfileLoginEvent.Name, backend.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) select { case slugs := <-heard: @@ -312,7 +313,7 @@ func TestALoginEndingReloadsTheProfilesBeforeTheScreenHearsIt(t *testing.T) { func TestAWindowFocusReadsTheProfilesAgainOnlyOnceTheyAreOld(t *testing.T) { stub := &rpcStub{answer: func(string, []json.RawMessage) (any, int) { - return []lichclient.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}}, http.StatusOK + return []backend.Profile{{Slug: personalProfile, Name: "Personal", LoggedIn: true}}, http.StatusOK }} m := newModel(stub.client(t), func() {}) ctx := context.Background() diff --git a/native/cmd/lich-native/terminal_drop.go b/native/cmd/lich-native/terminal_drop.go index 1e780fd92..df2c4da84 100644 --- a/native/cmd/lich-native/terminal_drop.go +++ b/native/cmd/lich-native/terminal_drop.go @@ -12,8 +12,8 @@ import ( "gioui.org/app" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // Files dropped on the terminal land at its prompt as paths. The window gets the files' own @@ -75,7 +75,7 @@ type dropResult struct { } // resolveDrop turns dropped paths into what the session can open. -func resolveDrop(ctx context.Context, up *lichclient.Client, target dropTarget, dropped []string) dropResult { +func resolveDrop(ctx context.Context, up *backend.Client, target dropTarget, dropped []string) dropResult { var r dropResult for _, path := range dropped { name := filepath.Base(path) @@ -108,10 +108,10 @@ func skippedDrop(name, reason string) string { return i18n.T("terminal.terminalDrop.skipped", i18n.Args{"name": name, "reason": reason}) } -func uploadDropped(ctx context.Context, up *lichclient.Client, sessionID, path string) (lichclient.Attachment, error) { +func uploadDropped(ctx context.Context, up *backend.Client, sessionID, path string) (backend.Attachment, error) { f, err := os.Open(path) if err != nil { - return lichclient.Attachment{}, err + return backend.Attachment{}, err } defer f.Close() return up.UploadDrop(ctx, sessionID, filepath.Base(path), f) diff --git a/native/cmd/lich-native/terminal_drop_test.go b/native/cmd/lich-native/terminal_drop_test.go index def058cc7..74ed0180e 100644 --- a/native/cmd/lich-native/terminal_drop_test.go +++ b/native/cmd/lich-native/terminal_drop_test.go @@ -18,7 +18,7 @@ import ( "gioui.org/app" "gioui.org/f32" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestParseURIListKeepsLocalFilesOnly(t *testing.T) { @@ -97,7 +97,7 @@ func TestTerminalDropLandsOnlyOnTheTerminal(t *testing.T) { // dropBackend is /drop as internal/drop serves it: the copy's path under the // session, and its notice. It records what it was sent. -func dropBackend(t *testing.T, uploads *[]string) *lichclient.Client { +func dropBackend(t *testing.T, uploads *[]string) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/drop" || r.URL.Query().Get("token") != "tok" { @@ -112,7 +112,7 @@ func dropBackend(t *testing.T, uploads *[]string) *lichclient.Client { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func writeFile(t *testing.T, path, body string) string { @@ -180,7 +180,7 @@ func TestResolveDropSkipsAFailedUpload(t *testing.T) { t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - c := lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + c := backend.New(backend.Runtime{Port: port, Token: "tok"}) away := writeFile(t, filepath.Join(t.TempDir(), "out.txt"), "out") r := resolveDrop(context.Background(), c, dropTarget{sessionID: "s1", checkout: t.TempDir(), confined: true}, []string{away}) if r.paths != nil || len(r.skipped) != 1 || !strings.Contains(r.skipped[0], "disk full (HTTP 500)") { diff --git a/native/cmd/lich-native/terminal_exit.go b/native/cmd/lich-native/terminal_exit.go index 153302b86..8784baf0d 100644 --- a/native/cmd/lich-native/terminal_exit.go +++ b/native/cmd/lich-native/terminal_exit.go @@ -2,23 +2,20 @@ package main import ( "context" - "encoding/json" "fmt" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) -// sessionExit is internal/terminal's exitEvent: the status the PTY child left, -// nil when it left none (killed by a signal), which is not a clean exit. -type sessionExit struct { - Code *int `json:"code,omitempty"` -} +type sessionExit = api.SessionExit -func readSessionExit(data json.RawMessage) sessionExit { - var e sessionExit - if json.Unmarshal(data, &e) != nil { +func readSessionExit(ev backend.Event) sessionExit { + id, _ := api.TerminalExitEvents.ID(ev.Name) + e, err := backend.Decode(ev, api.TerminalExitEvents.Of(id)) + if err != nil { return sessionExit{} } return e @@ -72,7 +69,7 @@ func (ts *terminals) restart(ctx context.Context, s sessionView, resume string) } cols, rows := t.vt.Size() if err := ts.start(ctx, s, resume, cols, rows); err != nil { - ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.view.restartFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.view.restartFailed", i18n.Args{"error": backend.ErrorText(err)})}) return false } ts.running(s.ID) diff --git a/native/cmd/lich-native/terminal_exit_test.go b/native/cmd/lich-native/terminal_exit_test.go index 610af82e9..3e8261238 100644 --- a/native/cmd/lich-native/terminal_exit_test.go +++ b/native/cmd/lich-native/terminal_exit_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "testing" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" +) func TestSessionExitWording(t *testing.T) { cases := []struct { @@ -12,7 +17,7 @@ func TestSessionExitWording(t *testing.T) { {`null`, "Shell 78 ended", "\r\n[process exited]\r\n"}, } for _, c := range cases { - e := readSessionExit([]byte(c.payload)) + e := readSessionExit(backend.Event{Name: api.TerminalExitEvents.Of("s78").Name, Data: []byte(c.payload)}) if got := exitTitle("Shell 78", e); got != c.title { t.Errorf("%s: title %q, want %q", c.payload, got, c.title) } diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index 24c3d7241..54a49d014 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -22,9 +22,9 @@ import ( "gioui.org/op/clip" "gioui.org/unit" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/gioterm" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -90,8 +90,8 @@ func (t *termView) refitDue(size image.Point, now time.Time) (bool, time.Time) { // stream carries every session's output; a session gets a termView, and from // then on all its output, the first time it is shown. type terminals struct { - client *lichclient.Client - stream *lichclient.Stream + client *backend.Client + stream *backend.Stream invalidate func() renderer string family string @@ -251,11 +251,11 @@ func (ts *terminals) show(ctx context.Context, gtx layout.Context, s sessionView ts.mu.Unlock() // A failed start leaves the terminal in place, empty. if err := ts.start(ctx, s, resume, cols, rows); err != nil { - if lichclient.ProfileGone(err) && ts.profileGone != nil { + if backend.ProfileGone(err) && ts.profileGone != nil { ts.profileGone(s) return t, nil } - ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.view.startFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + ts.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("terminal.view.startFailed", i18n.Args{"error": backend.ErrorText(err)})}) return t, nil } ts.mu.Lock() diff --git a/native/cmd/lich-native/themes.go b/native/cmd/lich-native/themes.go index 3ac94076e..ecf72bdd5 100644 --- a/native/cmd/lich-native/themes.go +++ b/native/cmd/lich-native/themes.go @@ -10,9 +10,9 @@ import ( "github.com/lichdotdev/godemirror" "github.com/lichdotdev/godemirror/tags" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/gioterm" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -47,7 +47,7 @@ var ansiTokens = [16]string{ // themeEntry is a theme this window can draw: its definition, and the // palette and terminal colors read out of it. type themeEntry struct { - def lichclient.Theme + def backend.Theme palette ui.Palette terminal gioterm.Colors code godemirror.Theme @@ -58,7 +58,7 @@ func (e themeEntry) light() bool { return e.def.Scheme == themeSchemeLight } // readThemes reads the backend's list in mergeThemes' order. A theme whose // colors this window cannot parse joins broken with the reason, rather than // drawing in colors it does not have. -func readThemes(defs []lichclient.Theme, broken []lichclient.BrokenTheme) ([]themeEntry, []lichclient.BrokenTheme) { +func readThemes(defs []backend.Theme, broken []backend.BrokenTheme) ([]themeEntry, []backend.BrokenTheme) { sorted := slices.Clone(defs) slices.SortStableFunc(sorted, compareThemes) entries := make([]themeEntry, 0, len(sorted)) @@ -66,7 +66,7 @@ func readThemes(defs []lichclient.Theme, broken []lichclient.BrokenTheme) ([]the for _, def := range sorted { e, err := readTheme(def) if err != nil { - broken = append(broken, lichclient.BrokenTheme{ID: def.ID, Reason: err.Error()}) + broken = append(broken, backend.BrokenTheme{ID: def.ID, Reason: err.Error()}) continue } entries = append(entries, e) @@ -76,7 +76,7 @@ func readThemes(defs []lichclient.Theme, broken []lichclient.BrokenTheme) ([]the // compareThemes is mergeThemes' sort: the bundled pair first in its own // order, then the imported ones by name. -func compareThemes(a, b lichclient.Theme) int { +func compareThemes(a, b backend.Theme) int { aBundled, bBundled := a.Origin == themeOriginBundled, b.Origin == themeOriginBundled switch { case aBundled != bBundled && aBundled: @@ -89,7 +89,7 @@ func compareThemes(a, b lichclient.Theme) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) } -func readTheme(def lichclient.Theme) (themeEntry, error) { +func readTheme(def backend.Theme) (themeEntry, error) { p, err := paletteOf(def) if err != nil { return themeEntry{}, err @@ -109,7 +109,7 @@ func readTheme(def lichclient.Theme) (themeEntry, error) { // text and the gutter in the theme's foreground and muted foreground, and the // theme's syntax highlighting when it has one. Each view lays its own ground // over Background. -func codeThemeOf(def lichclient.Theme, p ui.Palette) (godemirror.Theme, error) { +func codeThemeOf(def backend.Theme, p ui.Palette) (godemirror.Theme, error) { t := godemirror.DarkTheme if def.Scheme == themeSchemeLight { t = godemirror.LightTheme @@ -129,7 +129,7 @@ func codeThemeOf(def lichclient.Theme, p ui.Palette) (godemirror.Theme, error) { // highlightOf builds the HighlightStyle of a theme's syntax section. Two keys // can name one tag ("function(definition(x))" and "definition(function(x))"); // specs go in key order, so which one wins does not change between runs. -func highlightOf(syntax map[string]lichclient.SyntaxStyle) (*godemirror.HighlightStyle, error) { +func highlightOf(syntax map[string]backend.SyntaxStyle) (*godemirror.HighlightStyle, error) { specs := make([]godemirror.TagStyle, 0, len(syntax)) for _, key := range slices.Sorted(maps.Keys(syntax)) { tag, err := tags.Parse(key) @@ -151,7 +151,7 @@ func highlightOf(syntax map[string]lichclient.SyntaxStyle) (*godemirror.Highligh } // paletteOf reads the app tokens the native window draws with. -func paletteOf(def lichclient.Theme) (ui.Palette, error) { +func paletteOf(def backend.Theme) (ui.Palette, error) { var p ui.Palette fields := map[string]*color.NRGBA{ "background": &p.Background, "foreground": &p.Foreground, @@ -188,7 +188,7 @@ func paletteOf(def lichclient.Theme) (ui.Palette, error) { // terminalOf reads the terminal's defaults. Only the foreground and the // background are required; a missing cursor or ANSI color stays libghostty's. -func terminalOf(def lichclient.Theme) (gioterm.Colors, error) { +func terminalOf(def backend.Theme) (gioterm.Colors, error) { var c gioterm.Colors read := func(token string, into *color.NRGBA, required bool) error { css, ok := def.Terminal[token] @@ -284,7 +284,7 @@ func themeAfterRemoval(removedID, selected string) string { // themeCaption is the line beside a card's name: "in use" on the selected // one, else where it came from. -func themeCaption(def lichclient.Theme, selected string) string { +func themeCaption(def backend.Theme, selected string) string { switch { case def.ID == selected: return i18n.T("settings.themePicker.inUse") diff --git a/native/cmd/lich-native/themes_test.go b/native/cmd/lich-native/themes_test.go index b67f111f3..11512e54b 100644 --- a/native/cmd/lich-native/themes_test.go +++ b/native/cmd/lich-native/themes_test.go @@ -12,27 +12,27 @@ import ( "github.com/lichdotdev/godemirror" "github.com/lichdotdev/godemirror/tags" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) // bundledTheme reads themes/.json, the file the backend serves the // bundled themes from. -func bundledTheme(t *testing.T, id string) lichclient.Theme { +func bundledTheme(t *testing.T, id string) backend.Theme { t.Helper() data, err := os.ReadFile("../../../themes/" + id + ".json") if err != nil { t.Fatal(err) } - var th lichclient.Theme + var th backend.Theme if err := json.Unmarshal(data, &th); err != nil { t.Fatal(err) } return th } -func customTheme(id, name string) lichclient.Theme { - th := lichclient.Theme{ID: id, Name: name, Scheme: themeDark, Origin: themeOriginCustom, +func customTheme(id, name string) backend.Theme { + th := backend.Theme{ID: id, Name: name, Scheme: themeDark, Origin: themeOriginCustom, App: map[string]string{}, Terminal: map[string]string{"background": "#191724", "foreground": "#e0def4"}} for _, token := range []string{"background", "foreground", "card", "card-foreground", "popover", "popover-foreground", "primary", "primary-foreground", "secondary", "secondary-foreground", "muted", "muted-foreground", "accent", @@ -78,9 +78,9 @@ func rgb(v uint32) color.NRGBA { func TestReadThemesSortsAndSetsAsideWhatItCannotDraw(t *testing.T) { unreadable := customTheme("labbed", "Labbed") unreadable.App["background"] = "lab(50 20 30)" - defs := []lichclient.Theme{customTheme("zeta", "zeta"), bundledTheme(t, themeDark), unreadable, + defs := []backend.Theme{customTheme("zeta", "zeta"), bundledTheme(t, themeDark), unreadable, customTheme("alpha", "Alpha"), bundledTheme(t, themeLight)} - themes, broken := readThemes(defs, []lichclient.BrokenTheme{{ID: "old", Reason: "bad json"}}) + themes, broken := readThemes(defs, []backend.BrokenTheme{{ID: "old", Reason: "bad json"}}) var ids []string for _, e := range themes { ids = append(ids, e.def.ID) @@ -97,7 +97,7 @@ func TestReadThemesSortsAndSetsAsideWhatItCannotDraw(t *testing.T) { } func TestResolveTheme(t *testing.T) { - themes, _ := readThemes([]lichclient.Theme{bundledTheme(t, themeLight), bundledTheme(t, themeDark), customTheme("rose-pine", "Rosé Pine")}, nil) + themes, _ := readThemes([]backend.Theme{bundledTheme(t, themeLight), bundledTheme(t, themeDark), customTheme("rose-pine", "Rosé Pine")}, nil) cases := []struct { selected string systemDark bool @@ -125,7 +125,7 @@ func TestResolveThemeFollowsTheSystemToThePair(t *testing.T) { day.Scheme, day.Pair = "light", "forest-night" lone := customTheme("lone", "Lone") lone.Scheme = "dark" - themes, _ := readThemes([]lichclient.Theme{bundledTheme(t, themeLight), bundledTheme(t, themeDark), night, day, lone}, nil) + themes, _ := readThemes([]backend.Theme{bundledTheme(t, themeLight), bundledTheme(t, themeDark), night, day, lone}, nil) cases := []struct { selected string systemDark, followOS bool @@ -153,14 +153,14 @@ func TestAppearanceTakesASelectionStoredElsewhere(t *testing.T) { night.Scheme, night.Pair = "dark", "forest-day" day := customTheme("forest-day", "Forest Day") day.Scheme, day.Pair = "light", "forest-night" - f.themes = []lichclient.Theme{bundledTheme(t, themeLight), bundledTheme(t, themeDark), night, day} + f.themes = []backend.Theme{bundledTheme(t, themeLight), bundledTheme(t, themeDark), night, day} a := &appearance{ctx: context.Background(), client: client, invalidate: func() {}} if err := a.load(); err != nil { t.Fatal(err) } a.setSystemDark(false) a.take() - a.applySelection(lichclient.ThemeSelection{Theme: "forest-night", FollowSystem: true}) + a.applySelection(backend.ThemeSelection{Theme: "forest-night", FollowSystem: true}) if e, ok := a.take(); !ok || e.def.ID != "forest-day" { t.Errorf("following a light desktop from forest-night took %q, %v", e.def.ID, ok) } @@ -173,9 +173,9 @@ func TestAppearanceTakesASelectionStoredElsewhere(t *testing.T) { func TestThemeCaption(t *testing.T) { repo := customTheme("rose-pine", "Rosé Pine") - repo.Source = &lichclient.ThemeSource{URL: "https://github.com/lichdotdev/rose-pine-lich.git", Version: "1.2.0"} + repo.Source = &backend.ThemeSource{URL: "https://github.com/lichdotdev/rose-pine-lich.git", Version: "1.2.0"} cases := []struct { - def lichclient.Theme + def backend.Theme selected string want string }{ @@ -232,7 +232,7 @@ func TestThemeIsFoundBySearch(t *testing.T) { // choice resolves to once, and stores the choice. func TestAppearanceChoosesAndStores(t *testing.T) { f, client := newSidebarBackend(t) - f.themes = []lichclient.Theme{bundledTheme(t, themeDark), bundledTheme(t, themeLight), customTheme("rose-pine", "Rosé Pine")} + f.themes = []backend.Theme{bundledTheme(t, themeDark), bundledTheme(t, themeLight), customTheme("rose-pine", "Rosé Pine")} a := &appearance{ctx: context.Background(), client: client, invalidate: func() {}} if err := a.load(); err != nil { t.Fatal(err) @@ -290,7 +290,7 @@ func TestBundledThemesHighlightAsGodemirrorsDefaults(t *testing.T) { func TestThemeSyntaxReachesTheCodeViews(t *testing.T) { th := customTheme("pine", "Pine") - th.Syntax = map[string]lichclient.SyntaxStyle{ + th.Syntax = map[string]backend.SyntaxStyle{ "keyword": {Color: "#c4a7e7"}, "function(variableName)": {Color: "#ebbcba", Bold: true}, } @@ -314,7 +314,7 @@ func TestThemeSyntaxReachesTheCodeViews(t *testing.T) { // rather than drawing code in colors the author did not write. func TestABadSyntaxEntryBreaksTheTheme(t *testing.T) { th := customTheme("pine", "Pine") - th.Syntax = map[string]lichclient.SyntaxStyle{"keywords": {Color: "#fff"}} + th.Syntax = map[string]backend.SyntaxStyle{"keywords": {Color: "#fff"}} if _, err := readTheme(th); err == nil || !strings.Contains(err.Error(), `"keywords" is not a highlighting tag`) { t.Fatalf("readTheme error = %v", err) } diff --git a/native/cmd/lich-native/toasts.go b/native/cmd/lich-native/toasts.go index e83a44ef8..3755c2e36 100644 --- a/native/cmd/lich-native/toasts.go +++ b/native/cmd/lich-native/toasts.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -19,7 +19,7 @@ var ( func failure(format string, args ...any) ui.Toast { for i, arg := range args { if err, ok := arg.(error); ok { - args[i] = lichclient.ErrorText(err) + args[i] = backend.ErrorText(err) } } return ui.Toast{Variant: ui.ToastError, Title: fmt.Sprintf(format, args...)} @@ -43,7 +43,7 @@ func demoToasts() map[string]ui.Toast { "undo": {Title: "Closed Claude 3", Action: &ui.ToastButton{Label: "Undo"}}, "update": {Title: i18n.T("shell.appUpdateGate.available", i18n.Args{"version": "1.4.0"}), Cancel: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.later")}, Secondary: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.viewRelease")}, Action: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.updateAndInstall")}}, - "progress": updateProgressToast("1.4.0", &lichclient.AppUpdateProgress{Phase: lichclient.UpdatePhaseDownload, Received: 12_300_000, Total: 41_000_000}), + "progress": updateProgressToast("1.4.0", &backend.AppUpdateProgress{Phase: backend.UpdatePhaseDownload, Received: 12_300_000, Total: 41_000_000}), } } diff --git a/native/cmd/lich-native/topbar.go b/native/cmd/lich-native/topbar.go index add5154fe..ad3c0e9cb 100644 --- a/native/cmd/lich-native/topbar.go +++ b/native/cmd/lich-native/topbar.go @@ -13,8 +13,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -88,7 +88,7 @@ type topbarAction struct { notify *notification dismiss string // session id pick bool // the directory picker - reopen *lichclient.RecentProject + reopen *backend.RecentProject // settings is the Settings button; projectSettings a tab's "Project // settings…", by project id. settings bool diff --git a/native/cmd/lich-native/topbar_apply.go b/native/cmd/lich-native/topbar_apply.go index cafaac4e8..97440d27c 100644 --- a/native/cmd/lich-native/topbar_apply.go +++ b/native/cmd/lich-native/topbar_apply.go @@ -6,8 +6,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -91,7 +91,7 @@ func (u *window) applyTopbar(act topbarAction) { u.model.invalidate() go func() { if err := u.model.storeOrder(u.ctx, ids); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("tabs.topbarApply.reorderFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("tabs.topbarApply.reorderFailed", i18n.Args{"error": backend.ErrorText(err)})}) u.model.invalidate() } }() @@ -195,7 +195,7 @@ func (u *window) closeProject(id string) { go func() { sessions, err := u.model.askClose(u.ctx, id) if err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("tabs.topbarApply.closeProjectFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("tabs.topbarApply.closeProjectFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } for _, s := range sessions { @@ -251,7 +251,7 @@ func (u *window) pickProject() { // reopenProject opens a recent project; one whose directory is gone asks the // picker where it went, keeping its id. -func (u *window) reopenProject(r lichclient.RecentProject) { +func (u *window) reopenProject(r backend.RecentProject) { there, err := u.client.PathExists(u.ctx, r.Path) if err != nil { u.logErr("reopen project", err) @@ -263,21 +263,21 @@ func (u *window) reopenProject(r lichclient.RecentProject) { } moved, err := u.client.RelocateProject(u.ctx, r.ID) if err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.projects.relocateFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.projects.relocateFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } if moved == nil { return } if err := u.adopt(*moved); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.projects.relocateFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.projects.relocateFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("shell.projects.relocated", i18n.Args{"name": moved.Name, "path": displayPath(moved.Path)})}) } // adopt adds (or reopens) the project, reloads, and puts it on screen. -func (u *window) adopt(p lichclient.RecentProject) error { +func (u *window) adopt(p backend.RecentProject) error { if err := u.client.AddProject(u.ctx, p.ID, p.Name, p.Path); err != nil { return fmt.Errorf("add project: %w", err) } diff --git a/native/cmd/lich-native/topbar_menus.go b/native/cmd/lich-native/topbar_menus.go index f19a6b7e8..874e5c39f 100644 --- a/native/cmd/lich-native/topbar_menus.go +++ b/native/cmd/lich-native/topbar_menus.go @@ -12,8 +12,8 @@ import ( "gioui.org/op/paint" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -46,7 +46,7 @@ type openMenu struct { // snapshot is the menu's page of the closed projects and which of their // directories are gone. -func (o *openMenu) snapshot() ([]lichclient.RecentProject, map[string]bool) { +func (o *openMenu) snapshot() ([]backend.RecentProject, map[string]bool) { if o.closed == nil { return nil, nil } @@ -86,7 +86,7 @@ func (t *topbar) openButton(gtx C, th *ui.Theme, act *topbarAction) D { }) } -func (o *openMenu) content(gtx C, th *ui.Theme, recents []lichclient.RecentProject, missing map[string]bool) D { +func (o *openMenu) content(gtx C, th *ui.Theme, recents []backend.RecentProject, missing map[string]bool) D { children := []layout.FlexChild{ layout.Rigid(func(gtx C) D { return menuHeader(gtx, th, i18n.T("tabs.openProjectMenu.recent"), "") }), layout.Rigid(menuSeparator(th)), @@ -109,7 +109,7 @@ func (o *openMenu) content(gtx C, th *ui.Theme, recents []lichclient.RecentProje return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } -func recentRow(gtx C, th *ui.Theme, click *widget.Clickable, r lichclient.RecentProject, gone bool) D { +func recentRow(gtx C, th *ui.Theme, click *widget.Clickable, r backend.RecentProject, gone bool) D { return popoverRow(gtx, th, click, func(gtx C, th *ui.Theme) D { ic := icFolderPlain if gone { diff --git a/native/cmd/lich-native/tray.go b/native/cmd/lich-native/tray.go index 6fb7701ec..ead567c30 100644 --- a/native/cmd/lich-native/tray.go +++ b/native/cmd/lich-native/tray.go @@ -4,15 +4,15 @@ import ( "log" "os" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/fonts" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // trayLabels are the tray menu's words in the interface language. The // backend fills Running's {count} placeholder itself. -func trayLabels() lichclient.TrayLabels { - return lichclient.TrayLabels{ +func trayLabels() backend.TrayLabels { + return backend.TrayLabels{ Show: i18n.T("shell.tray.show"), Running: i18n.T("shell.tray.running", i18n.Args{"count": "{count}"}), Quit: i18n.T("shell.tray.quit"), @@ -55,7 +55,7 @@ func (u *window) followLanguage() { if u.uiFonts != nil && lang != u.fontsLanguage { u.fontsLanguage = lang if err := u.setInterfaceFonts(*u.uiFonts); err != nil { - u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.uiFontFailed", i18n.Args{"family": systemOr(u.uiFonts.sans, fonts.SystemSans), "error": lichclient.ErrorText(err)}))) + u.toaster.Show(fontFailure(i18n.T("settings.appearancePrefs.uiFontFailed", i18n.Args{"family": systemOr(u.uiFonts.sans, fonts.SystemSans), "error": backend.ErrorText(err)}))) } } sent, attached := u.trayLanguage.Load().(i18n.Locale) diff --git a/native/cmd/lich-native/turnstates_test.go b/native/cmd/lich-native/turnstates_test.go index 75bf85828..56aa6d8bf 100644 --- a/native/cmd/lich-native/turnstates_test.go +++ b/native/cmd/lich-native/turnstates_test.go @@ -10,11 +10,12 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) -func turnState(id, state string) lichclient.TurnState { - return lichclient.TurnState{ID: id, State: state, Since: t0.UnixMilli()} +func turnState(id, state string) backend.TurnState { + return backend.TurnState{ID: id, State: state, Since: t0.UnixMilli()} } func TestHydrationSeedsWhatTheWindowMissed(t *testing.T) { @@ -24,7 +25,7 @@ func TestHydrationSeedsWhatTheWindowMissed(t *testing.T) { waiting := turnState("s2", "waiting") waiting.Reason = "permission" - s.hydrate([]lichclient.TurnState{busy, waiting, turnState("s3", "idle"), turnState("s4", "interrupted")}) + s.hydrate([]backend.TurnState{busy, waiting, turnState("s3", "idle"), turnState("s4", "interrupted")}) if got := s.status("s1"); got != statusBusy { t.Errorf("s1 = %q, want busy", got) @@ -54,7 +55,7 @@ func TestHydrationLeavesFinishedTurnsToTheDatabase(t *testing.T) { s := newStatusStore() s.restoreUnread([]string{"unread"}) - s.hydrate([]lichclient.TurnState{turnState("unread", "done"), turnState("read", "done")}) + s.hydrate([]backend.TurnState{turnState("unread", "done"), turnState("read", "done")}) if !s.unread("unread") { t.Error("the unread turn lost its mark") @@ -70,7 +71,7 @@ func TestHydrationOutranksTheRestoredUnreadMark(t *testing.T) { s := newStatusStore() s.restoreUnread([]string{"s1"}) - s.hydrate([]lichclient.TurnState{turnState("s1", "busy")}) + s.hydrate([]backend.TurnState{turnState("s1", "busy")}) if got := s.status("s1"); got != statusBusy { t.Errorf("status = %q, want busy", got) @@ -83,7 +84,7 @@ func TestHydrationNeverOverwritesAnEventTheStreamDelivered(t *testing.T) { s := newStatusStore() replay(t, s, []step{{0, statusEvent{ID: "s1", State: "busy", Tool: "Edit"}}, {1, statusEvent{ID: "s1", State: "done"}}}) - s.hydrate([]lichclient.TurnState{{ID: "s1", State: "busy", Tool: "Bash", Since: t0.UnixMilli()}}) + s.hydrate([]backend.TurnState{{ID: "s1", State: "busy", Tool: "Bash", Since: t0.UnixMilli()}}) if got := s.status("s1"); got != statusDone { t.Errorf("status = %q, want done: the snapshot was older than the event", got) @@ -95,7 +96,7 @@ func TestHydrationNeverOverwritesAnEventTheStreamDelivered(t *testing.T) { func TestHydrationRunsOnceBeforeTheEventsItIsOlderThan(t *testing.T) { s := newStatusStore() - s.hydrate([]lichclient.TurnState{turnState("s1", "busy")}) + s.hydrate([]backend.TurnState{turnState("s1", "busy")}) replay(t, s, []step{{5, statusEvent{ID: "s1", State: "done"}}}) @@ -134,7 +135,7 @@ func TestUsageHydrationSeedsAndYieldsToTheStream(t *testing.T) { } } -func fakeTurnStates(t *testing.T, states []lichclient.TurnState) *lichclient.Client { +func fakeTurnStates(t *testing.T, states []backend.TurnState) *backend.Client { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/rpc/terminal.TurnStates" { @@ -145,13 +146,17 @@ func fakeTurnStates(t *testing.T, states []lichclient.TurnState) *lichclient.Cli t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) port, _ := strconv.Atoi(u.Port()) - return lichclient.New(lichclient.Runtime{Port: port, Token: "tok"}) + return backend.New(backend.Runtime{Port: port, Token: "tok"}) } func TestModelHydratesStatusAndUsageThenFollowsNewerEvents(t *testing.T) { - busy := lichclient.TurnState{ID: "s1", State: "busy", Tool: "Bash", Since: time.Now().UnixMilli(), - Usage: json.RawMessage(opusReport), Cwd: "/elsewhere"} - m := newTestModel(fakeTurnStates(t, []lichclient.TurnState{busy})) + var usage api.SessionUsage + if err := json.Unmarshal([]byte(opusReport), &usage); err != nil { + t.Fatal(err) + } + busy := backend.TurnState{ID: "s1", State: "busy", Tool: "Bash", Since: time.Now().UnixMilli(), + Usage: &usage, Cwd: "/elsewhere"} + m := newTestModel(fakeTurnStates(t, []backend.TurnState{busy})) toasts := 0 m.notify = func(statusReport) { toasts++ } @@ -175,7 +180,7 @@ func TestModelHydratesStatusAndUsageThenFollowsNewerEvents(t *testing.T) { t.Errorf("hydration notified %d times: nothing happened, the window only learned of it", toasts) } - send(m, statusEventName, statusEvent{ID: "s1", State: "done"}) + send(m, api.SessionStatusEvent.Name, statusEvent{ID: "s1", State: "done"}) m.mu.Lock() status = m.status.status("s1") m.mu.Unlock() @@ -195,7 +200,7 @@ func statusOf(m *model, id string) (status string, hasTool bool) { // publishes only the exit. func TestExitEndsTheTurnTheProcessDiedIn(t *testing.T) { m := newTestModel(nil) - send(m, statusEventName, statusEvent{ID: "s1", State: "busy", Tool: "Bash"}) + send(m, api.SessionStatusEvent.Name, statusEvent{ID: "s1", State: "busy", Tool: "Bash"}) if status, _ := statusOf(m, "s1"); status != statusBusy { t.Fatalf("status = %q, want busy before the exit", status) } @@ -210,9 +215,9 @@ func TestExitEndsTheTurnTheProcessDiedIn(t *testing.T) { func TestExitKeepsAFinishedTurnUnreadAndOtherSessionsBusy(t *testing.T) { m := newTestModel(nil) - m.projects[0].Sessions = append(m.projects[0].Sessions, lichclient.Session{ID: "s2"}) - send(m, statusEventName, statusEvent{ID: "s1", State: "done"}) - send(m, statusEventName, statusEvent{ID: "s2", State: "busy"}) + m.projects[0].Sessions = append(m.projects[0].Sessions, backend.Session{ID: "s2"}) + send(m, api.SessionStatusEvent.Name, statusEvent{ID: "s1", State: "done"}) + send(m, api.SessionStatusEvent.Name, statusEvent{ID: "s2", State: "busy"}) send(m, "terminal:exit:s1", map[string]any{}) diff --git a/native/cmd/lich-native/window_close.go b/native/cmd/lich-native/window_close.go index 88463287a..4d4eb35ee 100644 --- a/native/cmd/lich-native/window_close.go +++ b/native/cmd/lich-native/window_close.go @@ -10,8 +10,8 @@ import ( "gioui.org/layout" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -57,7 +57,7 @@ func (u *window) closeIsHeld() bool { func (u *window) askOrQuit(action string) { live, err := u.client.LiveCount(u.ctx) if err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.windowClose.countFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.windowClose.countFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } if closeQuits(action, live) { @@ -74,7 +74,7 @@ func (u *window) askOrQuit(action string) { func (u *window) quitLich() { if err := u.client.Quit(u.ctx); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.windowClose.quitFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("shell.windowClose.quitFailed", i18n.Args{"error": backend.ErrorText(err)})}) return } u.leave() diff --git a/native/cmd/lich-native/worktree_dialog.go b/native/cmd/lich-native/worktree_dialog.go index 722d94a93..829a6e5b7 100644 --- a/native/cmd/lich-native/worktree_dialog.go +++ b/native/cmd/lich-native/worktree_dialog.go @@ -12,8 +12,8 @@ import ( "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -34,7 +34,7 @@ const ( // uncommitted changes come along, and whether the session runs confined. // Fields under mu are written by the reads it starts. type worktreeDialog struct { - client *lichclient.Client + client *backend.Client root *ui.Root invalidate func() @@ -51,21 +51,21 @@ type worktreeDialog struct { cancel widget.Clickable create widget.Clickable - project lichclient.Project + project backend.Project kind string - source string // the checkout the carry copies from - checkout lichclient.DiffStats // that checkout's git status + source string // the checkout the carry copies from + checkout backend.DiffStats // that checkout's git status // fork is the session a fork-to-worktree branches, nil for the + menu's // new worktree. fork *forkSource mu sync.Mutex - branches *lichclient.Branches + branches *backend.Branches loadErr string base string checks map[string]*baseCheck issueRef int - issue *lichclient.Issue + issue *backend.Issue issueErr string canConfine bool // rungConfined is the box's starting value, applied by the frame once @@ -76,7 +76,7 @@ type worktreeDialog struct { done bool } -func newWorktreeDialog(c *lichclient.Client, root *ui.Root, invalidate func()) *worktreeDialog { +func newWorktreeDialog(c *backend.Client, root *ui.Root, invalidate func()) *worktreeDialog { return &worktreeDialog{client: c, root: root, invalidate: invalidate} } @@ -84,7 +84,7 @@ func newWorktreeDialog(c *lichclient.Client, root *ui.Root, invalidate func()) * // of p) and fork (nil for a new session), and starts the reads it draws: // branches, the sandbox rung, the base's freshness. A fork opens with its // checkout's uncommitted work coming along, the moment a fork is usually made. -func (d *worktreeDialog) show(ctx context.Context, p lichclient.Project, kind, source string, checkout lichclient.DiffStats, fork *forkSource) { +func (d *worktreeDialog) show(ctx context.Context, p backend.Project, kind, source string, checkout backend.DiffStats, fork *forkSource) { d.open, d.focus, d.pickerOpen = true, true, false d.picker.Close() d.project, d.kind, d.source, d.checkout, d.fork = p, kind, source, checkout, fork @@ -105,7 +105,7 @@ func (d *worktreeDialog) loadBranches(ctx context.Context) { b, err := d.client.ListBranches(ctx, d.project.Path) d.mu.Lock() if err != nil { - d.loadErr = lichclient.ErrorText(err) + d.loadErr = backend.ErrorText(err) } else { // Picking a worktree opens its session, which forks nothing. if d.fork != nil { @@ -124,7 +124,7 @@ func (d *worktreeDialog) loadBranches(ctx context.Context) { // preferredBase is the row the dialog opens on: the checkout's own branch, // else the first local one. -func preferredBase(b lichclient.Branches, current string) string { +func preferredBase(b backend.Branches, current string) string { for _, name := range b.Local { if name == current { return baseValue(baseLocal, name) @@ -140,8 +140,8 @@ func preferredBase(b lichclient.Branches, current string) string { // session's provider is on, which the box starts on (store.SandboxDefault: a // worktree is confined from "worktrees" up, and "ask" opens ticked). func (d *worktreeDialog) loadSandbox(ctx context.Context) { - var backend string - if err := d.client.Call(ctx, "system.SandboxBackend", &backend); err != nil { + backend, err := d.client.SandboxBackend(ctx) + if err != nil { log.Printf("worktree dialog: sandbox backend: %v", err) return } @@ -184,7 +184,7 @@ func (d *worktreeDialog) checkBase(ctx context.Context, base string) { d.mu.Lock() check.freshness = f if err != nil { - check.err = lichclient.ErrorText(err) + check.err = backend.ErrorText(err) } d.mu.Unlock() close(check.done) @@ -227,7 +227,7 @@ func (d *worktreeDialog) lookUpIssue(ctx context.Context, typed string) { d.mu.Lock() if d.issueRef == ref { if err != nil { - d.issueErr = lichclient.ErrorText(err) + d.issueErr = backend.ErrorText(err) } else { d.issue = &issue } @@ -239,12 +239,12 @@ func (d *worktreeDialog) lookUpIssue(ctx context.Context, typed string) { // worktreeView is one frame's read of the dialog's state. type worktreeView struct { - branches *lichclient.Branches + branches *backend.Branches loadErr string base string check *baseCheck issueRef int - issue *lichclient.Issue + issue *backend.Issue issueErr string canConfine bool submitting bool @@ -294,7 +294,7 @@ func (d *worktreeDialog) canCarry(base string) bool { // worktreeRequest is what Create hands the window, read off the frame. type worktreeRequest struct { - project lichclient.Project + project backend.Project kind string name string base string @@ -349,7 +349,7 @@ func (u *window) openWorktree(r worktreeRequest) { start, remote := startPoint(r.base, r.check, r.carry) wt, err := u.client.CreateWorktree(u.ctx, r.project.Path, r.project.ID, r.name, start, remote) if err != nil { - d.setSubmitting(lichclient.ErrorText(err)) + d.setSubmitting(backend.ErrorText(err)) return } if r.carry { @@ -357,7 +357,7 @@ func (u *window) openWorktree(r worktreeRequest) { } id, err := u.worktreeSession(r, wt) if err != nil { - d.setSubmitting(lichclient.ErrorText(err)) + d.setSubmitting(backend.ErrorText(err)) return } if r.brief != "" { @@ -376,19 +376,19 @@ func (d *worktreeDialog) closeAfter() { // carryInto copies the checkout's uncommitted work into the new worktree, // and says so when it cannot: the worktree is worth opening either way. -func (u *window) carryInto(from string, wt *lichclient.Worktree) { +func (u *window) carryInto(from string, wt *backend.Worktree) { if wt.Reused { u.toaster.Show(ui.Toast{Variant: ui.ToastWarning, Title: i18n.T("sidebar.worktreeDialog.carryReused", i18n.Args{"name": wt.Name})}) return } if err := u.client.CarryUncommitted(u.ctx, from, wt.Path); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.carryFailed", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.carryFailed", i18n.Args{"error": backend.ErrorText(err)})}) } } // worktreeSession is the new worktree's session: a fork of the dialog's // source, or a fresh one. -func (u *window) worktreeSession(r worktreeRequest, wt *lichclient.Worktree) (string, error) { +func (u *window) worktreeSession(r worktreeRequest, wt *backend.Worktree) (string, error) { if r.fork != nil { return u.addForkSession(r.project, *r.fork, wt.Path) } @@ -397,7 +397,7 @@ func (u *window) worktreeSession(r worktreeRequest, wt *lichclient.Worktree) (st // addWorktreeSession records the worktree's session, named after it, and // puts it on screen. -func (u *window) addWorktreeSession(r worktreeRequest, wt *lichclient.Worktree, sandbox string) (string, error) { +func (u *window) addWorktreeSession(r worktreeRequest, wt *backend.Worktree, sandbox string) (string, error) { id := strings.ToLower(rand.Text()) if err := u.client.AddSessionWithSandbox(u.ctx, r.project.ID, id, wt.Name, r.kind, wt.Path, r.project.NextSeq+1, sandbox); err != nil { return "", err @@ -411,20 +411,20 @@ func (u *window) reopenWorktree(r worktreeRequest, path string) { d := u.worktree restored, err := u.client.ReopenWorktreeSession(u.ctx, r.project.ID, path, strings.ToLower(rand.Text())) if err != nil { - d.setSubmitting(lichclient.ErrorText(err)) + d.setSubmitting(backend.ErrorText(err)) return } if restored == nil { name := path[strings.LastIndexAny(path, `/\`)+1:] - if _, err := u.addWorktreeSession(r, &lichclient.Worktree{Name: name, Path: path}, ""); err != nil { - d.setSubmitting(lichclient.ErrorText(err)) + if _, err := u.addWorktreeSession(r, &backend.Worktree{Name: name, Path: path}, ""); err != nil { + d.setSubmitting(backend.ErrorText(err)) return } d.closeAfter() return } if err := u.showNewSession(r.project.ID, restored.ID); err != nil { - d.setSubmitting(lichclient.ErrorText(err)) + d.setSubmitting(backend.ErrorText(err)) return } d.closeAfter() @@ -448,7 +448,7 @@ func (u *window) submitAtPrompt(id, message string) { return } if err := u.client.Submit(u.ctx, id, message); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.handoffLost", i18n.Args{"error": lichclient.ErrorText(err)})}) + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.handoffLost", i18n.Args{"error": backend.ErrorText(err)})}) } } diff --git a/native/cmd/lich-native/worktree_dialog_view.go b/native/cmd/lich-native/worktree_dialog_view.go index 6fa0864af..5eaee8532 100644 --- a/native/cmd/lich-native/worktree_dialog_view.go +++ b/native/cmd/lich-native/worktree_dialog_view.go @@ -13,8 +13,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" "github.com/lichdotdev/lich/native/ui/icons" ) @@ -35,7 +35,7 @@ type wtRow struct { // branchRows is the list the picker shows for query: worktrees to reopen, // then local and remote branches, each group headed and counted. -func branchRows(b *lichclient.Branches, query string) []wtRow { +func branchRows(b *backend.Branches, query string) []wtRow { if b == nil { return nil } diff --git a/native/cmd/lich-native/worktree_logic.go b/native/cmd/lich-native/worktree_logic.go index d3b9073e8..3c2f16add 100644 --- a/native/cmd/lich-native/worktree_logic.go +++ b/native/cmd/lich-native/worktree_logic.go @@ -8,8 +8,8 @@ import ( "unicode" "unicode/utf8" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" ) // Base options carry their group so one string names the selection: @@ -51,10 +51,10 @@ func parseIssueRef(typed string) int { return n } -func issueName(i lichclient.Issue) string { return fmt.Sprintf("%d %s", i.Number, i.Title) } +func issueName(i backend.Issue) string { return fmt.Sprintf("%d %s", i.Number, i.Title) } // issueBrief is the message the issue's session is sent as its first prompt. -func issueBrief(i lichclient.Issue) string { +func issueBrief(i backend.Issue) string { head := i18n.Prompt("prompts.issue.head", i18n.Args{"number": i.Number, "title": i.Title, "url": i.URL}) if body := strings.TrimSpace(i.Body); body != "" { return head + "\n\n" + body @@ -128,7 +128,7 @@ func toBranchName(typed string) string { // closed, then the backend's answer or why it could not give one. type baseCheck struct { done chan struct{} - freshness lichclient.BaseFreshness + freshness backend.BaseFreshness err string } diff --git a/native/cmd/lich-native/worktree_logic_test.go b/native/cmd/lich-native/worktree_logic_test.go index 741e521df..e8cf8107b 100644 --- a/native/cmd/lich-native/worktree_logic_test.go +++ b/native/cmd/lich-native/worktree_logic_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestToBranchName(t *testing.T) { @@ -36,7 +36,7 @@ func TestParseIssueRef(t *testing.T) { } } -func doneCheck(f lichclient.BaseFreshness, err string) *baseCheck { +func doneCheck(f backend.BaseFreshness, err string) *baseCheck { c := &baseCheck{done: make(chan struct{}), freshness: f, err: err} close(c.done) return c @@ -46,7 +46,7 @@ func doneCheck(f lichclient.BaseFreshness, err string) *baseCheck { // upstream, with nothing of its own, starts from the upstream; every other // case keeps the local branch. func TestStartPoint(t *testing.T) { - behind := lichclient.BaseFreshness{Upstream: "origin/main", Behind: 4} + behind := backend.BaseFreshness{Upstream: "origin/main", Behind: 4} cases := []struct { name string base string @@ -57,11 +57,11 @@ func TestStartPoint(t *testing.T) { }{ {"behind", "local:main", doneCheck(behind, ""), false, "origin/main", true}, {"behind but carrying", "local:main", doneCheck(behind, ""), true, "main", false}, - {"diverged", "local:main", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", Behind: 4, Ahead: 1}, ""), false, "main", false}, - {"up to date", "local:main", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main"}, ""), false, "main", false}, - {"no upstream", "local:main", doneCheck(lichclient.BaseFreshness{}, ""), false, "main", false}, - {"fetch failed", "local:main", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", Behind: 4, FetchError: "offline"}, ""), false, "main", false}, - {"call failed", "local:main", doneCheck(lichclient.BaseFreshness{}, "boom"), false, "main", false}, + {"diverged", "local:main", doneCheck(backend.BaseFreshness{Upstream: "origin/main", Behind: 4, Ahead: 1}, ""), false, "main", false}, + {"up to date", "local:main", doneCheck(backend.BaseFreshness{Upstream: "origin/main"}, ""), false, "main", false}, + {"no upstream", "local:main", doneCheck(backend.BaseFreshness{}, ""), false, "main", false}, + {"fetch failed", "local:main", doneCheck(backend.BaseFreshness{Upstream: "origin/main", Behind: 4, FetchError: "offline"}, ""), false, "main", false}, + {"call failed", "local:main", doneCheck(backend.BaseFreshness{}, "boom"), false, "main", false}, {"remote", "remote:origin/feat", nil, false, "origin/feat", true}, } for _, c := range cases { @@ -82,11 +82,11 @@ func TestFreshnessLine(t *testing.T) { warn bool }{ {"pending", pending, false, "Checking the remote…", false}, - {"behind", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", Behind: 4}, ""), false, "4 behind origin/main · starts from origin/main", false}, - {"carry", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", Behind: 4}, ""), true, "Starts from local main to keep your changes", false}, - {"ahead", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", Ahead: 2}, ""), false, "2 commits not pushed · starts from local main", false}, - {"offline", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main", FetchError: "x"}, ""), false, "Couldn't reach origin · starts from local main", true}, - {"up to date", doneCheck(lichclient.BaseFreshness{Upstream: "origin/main"}, ""), false, "", false}, + {"behind", doneCheck(backend.BaseFreshness{Upstream: "origin/main", Behind: 4}, ""), false, "4 behind origin/main · starts from origin/main", false}, + {"carry", doneCheck(backend.BaseFreshness{Upstream: "origin/main", Behind: 4}, ""), true, "Starts from local main to keep your changes", false}, + {"ahead", doneCheck(backend.BaseFreshness{Upstream: "origin/main", Ahead: 2}, ""), false, "2 commits not pushed · starts from local main", false}, + {"offline", doneCheck(backend.BaseFreshness{Upstream: "origin/main", FetchError: "x"}, ""), false, "Couldn't reach origin · starts from local main", true}, + {"up to date", doneCheck(backend.BaseFreshness{Upstream: "origin/main"}, ""), false, "", false}, } for _, c := range cases { text, warn := freshnessLine("main", c.check, c.carry) diff --git a/native/go.mod b/native/go.mod index b9b89caa2..42749870f 100644 --- a/native/go.mod +++ b/native/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-text/typesetting v0.3.5 github.com/godbus/dbus/v5 v5.2.2 github.com/lichdotdev/godemirror v0.8.0 + github.com/lichdotdev/lich v0.0.0 github.com/yuin/goldmark v1.4.13 golang.org/x/exp/shiny v0.0.0-20261009195045-ca0d7ba23607 golang.org/x/image v0.47.0 @@ -33,3 +34,5 @@ require ( // buttons on an undecorated window), and the Editor's scroll offset (the // textarea's scrollbar). Every patch of ours lands on that branch. replace gioui.org => github.com/lichdotdev/gio v0.0.0-20261022192911-12496229bb5d + +replace github.com/lichdotdev/lich => ../ diff --git a/native/lichclient/api.go b/native/lichclient/api.go deleted file mode 100644 index 07fde2181..000000000 --- a/native/lichclient/api.go +++ /dev/null @@ -1,190 +0,0 @@ -package lichclient - -import ( - "context" - "encoding/base64" - "fmt" -) - -// Project mirrors store.Project (internal/store/store.go), trimmed to what the -// native client reads. -type Project struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - NextSeq int `json:"nextSeq"` - ActiveSessionID string `json:"activeSessionId"` - DefaultProvider string `json:"defaultProvider"` - Sessions []Session `json:"sessions"` -} - -// Session mirrors store.Session, trimmed. Path is the checkout the session -// runs in; Kind is the provider (claude, codex, ...) or "shell". -type Session struct { - ID string `json:"id"` - Label string `json:"label"` - Kind string `json:"kind"` - Path string `json:"path"` - Folder string `json:"folder"` - Pinned bool `json:"pinned"` - Unread bool `json:"unread"` - Color string `json:"color"` - // ProviderSessionID is the provider conversation the session last ran, - // "" for none: what a restored card offers to resume. - ProviderSessionID string `json:"providerSessionId"` - // MCPServers are the servers the session's spawn found registered with - // its provider, which divide an MCP tool's name into server and tool. - MCPServers []string `json:"mcpServers"` - // Profile is the Claude Code profile the session runs in (a slug), "" for - // Personal and for every other provider. - Profile string `json:"profile"` - // Model is the model the session was born on, "" for one opened on its - // provider's default. - Model string `json:"model"` - // OriginSessionID is the session this one came from, in any project, "" - // for one nobody opened from another; OriginLabel is what that session - // was called when it did. - OriginSessionID string `json:"originSessionId"` - OriginLabel string `json:"originLabel"` - // Forked is whether the session was born branching that session's - // conversation, rather than delegated or opened as a subagent. - Forked bool `json:"forked"` -} - -// DiffStats mirrors project.DiffStats. -type DiffStats struct { - Files int `json:"files"` - Added int `json:"added"` - Deleted int `json:"deleted"` - Head string `json:"head"` - Branch string `json:"branch"` -} - -// LoadState returns every open project with its sessions. -func (c *Client) LoadState(ctx context.Context) ([]Project, error) { - var ps []Project - return ps, c.Call(ctx, "store.LoadState", &ps) -} - -// AddProject opens path as a project under id. -func (c *Client) AddProject(ctx context.Context, id, name, path string) error { - return c.Call(ctx, "store.AddProject", nil, id, name, path) -} - -// AddSession records a new session row; Start then spawns its process. -func (c *Client) AddSession(ctx context.Context, projectID, sessionID, label, kind, path string, nextSeq int) error { - return c.Call(ctx, "store.AddSession", nil, projectID, sessionID, label, kind, path, nextSeq, "") -} - -// SetActiveSession records which session a project shows. -func (c *Client) SetActiveSession(ctx context.Context, projectID, sessionID string) error { - return c.Call(ctx, "store.SetActiveSession", nil, projectID, sessionID) -} - -// Replay returns the tail of a session's output, to write into a fresh -// terminal before live output is applied. -func (c *Client) Replay(ctx context.Context, id string) ([]byte, error) { - var b64 string - if err := c.Call(ctx, "terminal.Replay", &b64, id); err != nil { - return nil, err - } - data, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - return nil, fmt.Errorf("terminal.Replay: %w", err) - } - return data, nil -} - -// SessionConversationEvent is the event a session's row naming another -// provider conversation emits (terminal conversationEventName). -const SessionConversationEvent = "session-conversation" - -// ConversationReported is the session-conversation payload: ProviderSessionID -// is the conversation the session now names, "" for none. -type ConversationReported struct { - ID string `json:"id"` - ProviderSessionID string `json:"providerSessionId"` -} - -// Start spawns the session's process at cols x rows; a no-op when it already -// runs. resume is a provider conversation id to reopen, "" to start fresh. -func (c *Client) Start(ctx context.Context, id, projectID, cwd, kind, resume string, cols, rows int) error { - return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, resume, false, cols, rows) -} - -// Fork spawns the session's process branching the provider conversation -// parent: the conversation is copied into one of its own, and the card that -// runs parent goes on untouched. A no-op when the session already runs. -func (c *Client) Fork(ctx context.Context, id, projectID, cwd, kind, parent string, cols, rows int) error { - return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, parent, true, cols, rows) -} - -// Resize sets the session's PTY size. -func (c *Client) Resize(ctx context.Context, id string, cols, rows int) error { - return c.Call(ctx, "terminal.Resize", nil, id, cols, rows) -} - -// SetVisible tells the backend whether the session is on screen, which sets -// how long it batches output before sending. -func (c *Client) SetVisible(ctx context.Context, id string, visible bool) error { - return c.Call(ctx, "terminal.SetVisible", nil, id, visible) -} - -// Diff returns a checkout's branch and change counts. -func (c *Client) Diff(ctx context.Context, path string) (DiffStats, error) { - var d DiffStats - return d, c.Call(ctx, "project.Diff", &d, path) -} - -// DiffText returns one unified diff of a checkout's staged, unstaged and -// untracked changes against HEAD. -func (c *Client) DiffText(ctx context.Context, path string) (string, error) { - var s string - return s, c.Call(ctx, "project.DiffText", &s, path) -} - -// FileLines returns lines from..to (1-based, inclusive) of rel at ref, "" for -// the working tree. The backend caps one answer at 500 lines, so a caller -// wanting more asks again from where the answer ended. -func (c *Client) FileLines(ctx context.Context, path, rel, ref string, from, to int) ([]string, error) { - var lines []string - return lines, c.Call(ctx, "project.FileLines", &lines, path, rel, ref, from, to) -} - -// RevertLine mirrors project.RevertLine: one changed line of a drawn diff, -// "old" for a deletion numbered in HEAD and "new" for an addition numbered in -// the working tree. Text proves the number still means the same line. -type RevertLine struct { - Side string `json:"side"` - Line int `json:"line"` - Text string `json:"text"` -} - -// RevertResult mirrors project.RevertResult, what undoing a revert needs. Both -// patches are base64, opaque here: handed back to RestoreLines as they came. -type RevertResult struct { - Patch string `json:"patch"` - IndexPatch string `json:"indexPatch"` -} - -// RevertLines puts these changed lines of rel back to HEAD, in the index too -// when it holds them. It fails when the file moved on since the diff was -// drawn. -func (c *Client) RevertLines(ctx context.Context, path, rel string, lines []RevertLine) (RevertResult, error) { - var r RevertResult - return r, c.Call(ctx, "project.RevertLines", &r, path, rel, lines) -} - -// GetSetting returns a setting's value, "" when unset. scope is a project id, -// "" for a global setting. -func (c *Client) GetSetting(ctx context.Context, key, scope string) (string, error) { - var v string - return v, c.Call(ctx, "store.GetSetting", &v, key, scope) -} - -// HomeProject is the project rooted at the user's home directory, the one the -// windows pin as Home. It is not opened by asking. -func (c *Client) HomeProject(ctx context.Context) (Project, error) { - var p Project - return p, c.Call(ctx, "project.Home", &p) -} diff --git a/native/lichclient/footer.go b/native/lichclient/footer.go deleted file mode 100644 index 628ba5f91..000000000 --- a/native/lichclient/footer.go +++ /dev/null @@ -1,65 +0,0 @@ -package lichclient - -import "context" - -// QuotaWindow mirrors quota.Window (internal/quota/quota.go). -type QuotaWindow struct { - Label string `json:"label"` - Seconds int `json:"seconds"` - Percent int `json:"percent"` - ResetsAt string `json:"resetsAt,omitempty"` - Active bool `json:"active,omitempty"` - LockedReason string `json:"lockedReason,omitempty"` - Ahead bool `json:"ahead,omitempty"` -} - -// QuotaPlan mirrors quota.Plan. Status is "ok", "signed-out", "error" or -// "unknown"; NoAccount is "token-login" or "". -type QuotaPlan struct { - Provider string `json:"provider"` - Name string `json:"name"` - Plan string `json:"plan,omitempty"` - Account string `json:"account,omitempty"` - NoAccount string `json:"noAccount,omitempty"` - Windows []QuotaWindow `json:"windows,omitempty"` - Status string `json:"status"` -} - -// Attachment mirrors drop.Attachment: Path is "" when the dialog was -// cancelled, and Notice is the line to paste under a path that is a copy's. -type Attachment struct { - Path string `json:"path"` - Notice string `json:"notice"` -} - -// QuotaPlans reads every metered plan for the account sessionID spends; "" -// reads the machine's own login. The backend serves it from a five-minute -// cache. -func (c *Client) QuotaPlans(ctx context.Context, sessionID string) ([]QuotaPlan, error) { - var plans []QuotaPlan - return plans, c.Call(ctx, "quota.Plans", &plans, sessionID) -} - -// HandsOn is how long session id has been worked on, in whole seconds. -func (c *Client) HandsOn(ctx context.Context, id string) (int64, error) { - var seconds int64 - return seconds, c.Call(ctx, "terminal.HandsOn", &seconds, id) -} - -// Attach opens the backend's file chooser for session sessionID. root is the -// session's checkout and confined whether it runs in the sandbox: a file -// outside what the session can read comes back as a copy's path. -func (c *Client) Attach(ctx context.Context, sessionID, root string, confined bool) (Attachment, error) { - var a Attachment - return a, c.Call(ctx, "drop.Attach", &a, sessionID, root, confined) -} - -// Write types data into session id's PTY. -func (c *Client) Write(ctx context.Context, id, data string) error { - return c.Call(ctx, "terminal.Write", nil, id, data) -} - -// SetSetting writes a setting; scope is a project id, "" for a global one. -func (c *Client) SetSetting(ctx context.Context, key, scope, value string) error { - return c.Call(ctx, "store.SetSetting", nil, key, scope, value) -} diff --git a/native/lichclient/palette.go b/native/lichclient/palette.go deleted file mode 100644 index af0746329..000000000 --- a/native/lichclient/palette.go +++ /dev/null @@ -1,116 +0,0 @@ -package lichclient - -import "context" - -// ClosedSession mirrors store.ClosedSession: a parked session as the command -// palette's History tab lists it. ParkedBranch is the branch recorded at the -// close, the searchable snapshot; the branch a row shows is read live from the -// checkout (BranchesOf). ClosedAt is Unix seconds, 0 for a row parked before -// lich recorded the close. -type ClosedSession struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` - ProjectName string `json:"projectName"` - ProjectPath string `json:"projectPath"` - Label string `json:"label"` - Kind string `json:"kind"` - Path string `json:"path"` - ParkedBranch string `json:"parkedBranch"` - ClosedAt int64 `json:"closedAt"` - // MatchedConversation is whether the search reached the row through its - // conversation, which keeps it listed even when Snippet is empty. - MatchedConversation bool `json:"matchedConversation"` - Snippet string `json:"snippet"` - // Truncated is whether the index kept only the newest part of a - // conversation too long to hold whole. - Truncated bool `json:"truncated"` -} - -// ClosedHistory mirrors store.ClosedHistory: one page of parked sessions, the -// size of the match it was cut from, and how many parked conversations the -// store has not indexed yet. -type ClosedHistory struct { - Sessions []ClosedSession `json:"sessions"` - Total int `json:"total"` - Indexing int `json:"indexing"` -} - -// ExternalSession mirrors store.ExternalSession: a conversation started -// outside lich in a known project's checkout. Path is "" for the project's own -// directory; Profile is the Claude Code profile whose directory holds it. -type ExternalSession struct { - Kind string `json:"kind"` - ProviderSessionID string `json:"providerSessionId"` - Title string `json:"title"` - Path string `json:"path"` - ProjectID string `json:"projectId"` - ProjectName string `json:"projectName"` - ProjectPath string `json:"projectPath"` - UpdatedAt int64 `json:"updatedAt"` - Profile string `json:"profile,omitempty"` -} - -// TranscriptMatch mirrors terminal.TranscriptMatch: a session whose -// conversation mentions a query, with its newest matching message. -type TranscriptMatch struct { - ID string `json:"id"` - Snippet string `json:"snippet"` - Count int `json:"count"` -} - -// RecentProjectsMatching is RecentProjects narrowed to the closed projects -// whose name or path hold every word of term. -func (c *Client) RecentProjectsMatching(ctx context.Context, term string) ([]RecentProject, error) { - var rows []RecentProject - return rows, c.Call(ctx, "store.RecentProjects", &rows, term) -} - -// ClosedProjectCountMatching counts every closed project matching term, past -// the page RecentProjectsMatching answers with. -func (c *Client) ClosedProjectCountMatching(ctx context.Context, term string) (int, error) { - var n int - return n, c.Call(ctx, "store.ClosedProjectCount", &n, term) -} - -// ClosedSessions searches the parked sessions by name, project, path, branch -// and conversation; an empty term is the plain history, newest close first. -// A non-empty term also starts indexing any conversation not indexed yet. -func (c *Client) ClosedSessions(ctx context.Context, term string) (ClosedHistory, error) { - var h ClosedHistory - return h, c.Call(ctx, "store.ClosedSessions", &h, term) -} - -// ExternalSessions lists the conversations started outside lich in a known -// project's checkouts that no session row holds, newest first. -func (c *Client) ExternalSessions(ctx context.Context) ([]ExternalSession, error) { - var rows []ExternalSession - return rows, c.Call(ctx, "store.ExternalSessions", &rows) -} - -// AdoptExternalSession files an external conversation as a parked session of -// projectID under sessionID, which ReopenSession then brings back. It fails -// when a session already holds the conversation. -func (c *Client) AdoptExternalSession(ctx context.Context, projectID, sessionID, kind, path, providerSessionID, label string) error { - return c.Call(ctx, "store.AdoptExternalSession", nil, projectID, sessionID, kind, path, providerSessionID, label) -} - -// ForgetSession deletes one parked session for good. It fails on a session -// that is open. -func (c *Client) ForgetSession(ctx context.Context, sessionID string) error { - return c.Call(ctx, "store.ForgetSession", nil, sessionID) -} - -// SearchTranscripts finds which of the sessions ids have talked about query -// and what they said, in the order the sessions were given. A query under -// three characters is refused and answers none. -func (c *Client) SearchTranscripts(ctx context.Context, ids []string, query string) ([]TranscriptMatch, error) { - var matches []TranscriptMatch - return matches, c.Call(ctx, "terminal.SearchTranscripts", &matches, ids, query) -} - -// BranchesOf names the branch each checkout path is on now; a path that is no -// repository is absent from the answer. -func (c *Client) BranchesOf(ctx context.Context, paths []string) (map[string]string, error) { - var named map[string]string - return named, c.Call(ctx, "project.BranchesOf", &named, paths) -} diff --git a/native/lichclient/profile.go b/native/lichclient/profile.go deleted file mode 100644 index 2e392ec83..000000000 --- a/native/lichclient/profile.go +++ /dev/null @@ -1,225 +0,0 @@ -package lichclient - -import ( - "context" - "strings" -) - -const ( - // ClaudeProfileLoginEvent and CodexProfileLoginEvent are the events a - // profile login reports its progress on (claudeprofile.LoginEventName, - // codexprofile.LoginEventName). - ClaudeProfileLoginEvent = "claude-profile-login" - CodexProfileLoginEvent = "codex-profile-login" - - // SessionProfileEvent is the event a session moving to another profile - // emits (terminal profileEventName). - SessionProfileEvent = "session-profile" - - // ProfileChangedEvent is the event a profile's update emits once it is - // written (profilestore.ChangedEvent). - ProfileChangedEvent = "profile-changed" -) - -// profileGoneText is what internal/claudeprofile.ErrProfileGone and -// internal/codexprofile.ErrProfileGone both end in, which a spawn in a profile -// deleted since answers. -const profileGoneText = "profile no longer exists" - -// Profile mirrors claudeprofile.Info, codexprofile.Info and ompprofile.Info: a -// login lich keeps a state directory for. Slug is "personal" for the default -// profile. Email, OrgName and Plan are what `claude auth status` found there; -// a Codex profile carries Email and Plan from its plan reading, and an oh-my-pi -// one Email (every account's address) and Providers. Plugins counts a Claude -// Code or Codex profile's plugins. -type Profile struct { - Slug string `json:"slug"` - Name string `json:"name"` - Personal bool `json:"personal"` - // Copied names the shared entries the profile holds as copies rather than - // links (Windows without Developer Mode). - Copied []string `json:"copied,omitempty"` - LoggedIn bool `json:"loggedIn"` - Email string `json:"email,omitempty"` - OrgName string `json:"orgName,omitempty"` - Plan string `json:"plan,omitempty"` - Plugins int `json:"plugins"` - // Providers are the providers an oh-my-pi profile holds a login for. - Providers []string `json:"providers,omitempty"` - StatusError string `json:"statusError,omitempty"` - // Color is a card color's name, "" for the one the window derives from the - // slug; Image is the profile's picture, a square PNG. - Color string `json:"color,omitempty"` - Image []byte `json:"image,omitempty"` -} - -// ProfileUpdate mirrors profilestore.Update: a nil field keeps what the profile -// has, and an empty Name keeps its name. Color "" goes back to the slug's own -// color; Image is a file to read, "" removes the image. -type ProfileUpdate struct { - Name string `json:"name,omitempty"` - Color *string `json:"color,omitempty"` - Image *string `json:"image,omitempty"` -} - -// ProfilePlugin mirrors claudeprofile.Plugin and codexprofile.Plugin. -type ProfilePlugin struct { - Key string `json:"key"` - Name string `json:"name"` - Marketplace string `json:"marketplace"` -} - -// ProfilePluginResult mirrors claudeprofile.PluginResult and -// codexprofile.PluginResult: Message is the CLI's own words when it refused one. -type ProfilePluginResult struct { - Key string `json:"key"` - OK bool `json:"ok"` - Message string `json:"message,omitempty"` -} - -// ProfileLogin mirrors claudeprofile.LoginEvent and codexprofile.LoginEvent: -// Output (and URL, when that text holds the sign-in address) while the CLI -// talks, Authenticating once it is through with the browser, then one event -// with Done set. -type ProfileLogin struct { - Profile string `json:"profile"` - Output string `json:"output,omitempty"` - URL string `json:"url,omitempty"` - Authenticating bool `json:"authenticating,omitempty"` - Done bool `json:"done,omitempty"` - LoggedIn bool `json:"loggedIn,omitempty"` - Cancelled bool `json:"cancelled,omitempty"` - Error string `json:"error,omitempty"` - PluginError string `json:"pluginError,omitempty"` -} - -// ProfileChanged mirrors profilestore.Changed: Provider is the provider id -// ("claude", "kiro"), Profile the profile as it now is, without its login -// state. -type ProfileChanged struct { - Provider string `json:"provider"` - Profile Profile `json:"profile"` -} - -// ProfileMoved is the session-profile payload: Profile is the slug the session -// now runs in, "" for Personal. -type ProfileMoved struct { - ID string `json:"id"` - Profile string `json:"profile"` -} - -// ProfileAPI is one provider's profile service: the claudeprofile, codexprofile -// or ompprofile one, which answer the same calls. A Codex login has no sign-in -// code to hand over, so LoginCode is Claude Code's alone, and Plugins and -// InstallPlugins are Claude Code's and Codex's. oh-my-pi's login is a terminal -// card's (LoginCommand), so it has no Login, LoginCode or CancelLogin, and no -// plan. -type ProfileAPI struct { - c *Client - service string - plan string -} - -// ClaudeProfiles is the Claude Code profile service. -func (c *Client) ClaudeProfiles() ProfileAPI { - return ProfileAPI{c: c, service: "claudeprofile", plan: "quota.ProfilePlan"} -} - -// CodexProfiles is the Codex profile service. -func (c *Client) CodexProfiles() ProfileAPI { - return ProfileAPI{c: c, service: "codexprofile", plan: "quota.CodexProfilePlan"} -} - -// OMPProfiles is the oh-my-pi profile service. -func (c *Client) OMPProfiles() ProfileAPI { - return ProfileAPI{c: c, service: "ompprofile"} -} - -// XDGProfiles is the profile service of opencode, Cursor CLI or Kiro CLI, by -// provider id (xdgprofile.Service, "profile"). -func (c *Client) XDGProfiles(kind string) ProfileAPI { - return ProfileAPI{c: c, service: kind + "profile"} -} - -// List lists every profile with its login state; the backend runs one CLI per -// profile to answer. -func (a ProfileAPI) List(ctx context.Context) ([]Profile, error) { - var list []Profile - return list, a.c.Call(ctx, a.service+".Profiles", &list) -} - -// Create makes a profile named name and returns it; logging it in is Login's. -func (a ProfileAPI) Create(ctx context.Context, name string) (Profile, error) { - var p Profile - return p, a.c.Call(ctx, a.service+".CreateProfile", &p, name) -} - -// Update changes a profile's name, color or image and returns it as the backend -// now lists it, without its login state. -func (a ProfileAPI) Update(ctx context.Context, slug string, u ProfileUpdate) (Profile, error) { - var p Profile - return p, a.c.Call(ctx, a.service+".UpdateProfile", &p, slug, u) -} - -// Delete removes a profile and its login; the backend refuses while a session -// runs in it. -func (a ProfileAPI) Delete(ctx context.Context, slug string) error { - return a.c.Call(ctx, a.service+".DeleteProfile", nil, slug) -} - -// Plugins lists the plugins a profile has at user scope, the ones a port can -// carry to another profile. Claude Code and Codex only. -func (a ProfileAPI) Plugins(ctx context.Context, slug string) ([]ProfilePlugin, error) { - var list []ProfilePlugin - return list, a.c.Call(ctx, a.service+".Plugins", &list, slug) -} - -// InstallPlugins installs keys (plugin@marketplace) into a logged-in profile, -// one result per key. Claude Code and Codex only. -func (a ProfileAPI) InstallPlugins(ctx context.Context, slug string, keys []string) ([]ProfilePluginResult, error) { - var results []ProfilePluginResult - return results, a.c.Call(ctx, a.service+".InstallPlugins", &results, slug, keys) -} - -// Login starts the provider's login as a profile and returns once the CLI -// runs; its progress arrives as ClaudeProfileLoginEvent or -// CodexProfileLoginEvent events. -func (a ProfileAPI) Login(ctx context.Context, slug string) error { - return a.c.Call(ctx, a.service+".Login", nil, slug) -} - -// LoginCode hands a running login the code the sign-in page showed. Claude Code -// only. -func (a ProfileAPI) LoginCode(ctx context.Context, slug, code string) error { - return a.c.Call(ctx, a.service+".LoginCode", nil, slug, code) -} - -// CancelLogin ends a login lich started. -func (a ProfileAPI) CancelLogin(ctx context.Context, slug string) error { - return a.c.Call(ctx, a.service+".CancelLogin", nil, slug) -} - -// LoginCommand is the command line that logs a profile in from a terminal. -func (a ProfileAPI) LoginCommand(ctx context.Context, slug string) (string, error) { - var command string - return command, a.c.Call(ctx, a.service+".LoginCommand", &command, slug) -} - -// Plan reads one profile's plan; "personal" is the machine's own reading. -func (a ProfileAPI) Plan(ctx context.Context, slug string) (QuotaPlan, error) { - var plan QuotaPlan - return plan, a.c.Call(ctx, a.plan, &plan, slug) -} - -// ContinueIn moves a session's conversation to another profile of its provider -// and carries on there. The backend refuses a turn in progress, and a provider -// whose conversations cannot move (opencode). -func (c *Client) ContinueIn(ctx context.Context, id, slug string) error { - return c.Call(ctx, "terminal.ContinueIn", nil, id, slug) -} - -// ProfileGone reports whether err is a spawn refused for a profile deleted -// since the session was born in it. -func ProfileGone(err error) bool { - return err != nil && strings.Contains(ErrorText(err), profileGoneText) -} diff --git a/native/lichclient/project.go b/native/lichclient/project.go deleted file mode 100644 index 30c3c6251..000000000 --- a/native/lichclient/project.go +++ /dev/null @@ -1,82 +0,0 @@ -package lichclient - -import "context" - -// BaseStatus mirrors project.BaseStatus: where a checkout stands against the -// branch it merges into. -type BaseStatus struct { - Base string `json:"base"` - Behind int `json:"behind"` - Conflicts []string `json:"conflicts"` -} - -// PullRequest mirrors project.PullRequest. -type PullRequest struct { - Number int `json:"number"` - URL string `json:"url"` - State string `json:"state"` -} - -// BaseStatus returns where path stands against its base, nil when it has no -// base to stand against (the base branch itself, no remote). -func (c *Client) BaseStatus(ctx context.Context, path string) (*BaseStatus, error) { - var b *BaseStatus - return b, c.Call(ctx, "project.BaseStatus", &b, path) -} - -// PullRequest returns the open pull request of path's branch through gh, nil -// when there is none or gh cannot answer. Each call is a network round trip. -func (c *Client) PullRequest(ctx context.Context, path string) (*PullRequest, error) { - var pr *PullRequest - return pr, c.Call(ctx, "project.PullRequest", &pr, path) -} - -// OpenExternal opens url in the user's browser. -func (c *Client) OpenExternal(ctx context.Context, url string) error { - return c.Call(ctx, "system.OpenExternal", nil, url) -} - -// OpenFolderInEditor opens dir in the user's editor. A GUI editor is launched -// detached and the reply is ""; a terminal editor comes back as the command -// line to run in a shell at dir. -func (c *Client) OpenFolderInEditor(ctx context.Context, dir string) (string, error) { - var command string - return command, c.Call(ctx, "system.OpenFolderInEditor", &command, dir) -} - -// OpenFolder opens dir in the file manager. -func (c *Client) OpenFolder(ctx context.Context, dir string) error { - return c.Call(ctx, "system.OpenFolder", nil, dir) -} - -// Diagnostics mirrors system.Diagnostics: what a bug report needs. -type Diagnostics struct { - Version string `json:"version"` - Platform string `json:"platform"` - LogPath string `json:"logPath"` // "" when lich logs to stderr only -} - -func (c *Client) Diagnostics(ctx context.Context) (Diagnostics, error) { - var d Diagnostics - return d, c.Call(ctx, "system.Diagnostics", &d) -} - -// RevealLog opens the log's directory in the file manager; it fails when -// there is no log file. -func (c *Client) RevealLog(ctx context.Context) error { - return c.Call(ctx, "system.RevealLog", nil) -} - -// TakeUncleanExit reports whether lich's run before this one ended without -// quitting cleanly. The backend clears the flag on the first ask, so only -// the first window to ask is told. -func (c *Client) TakeUncleanExit(ctx context.Context) (bool, error) { - var unclean bool - return unclean, c.Call(ctx, "system.TakeUncleanExit", &unclean) -} - -// OpenFile opens rel, a file of the checkout at dir, in the application the -// desktop opens its type with, never in the user's editor. -func (c *Client) OpenFile(ctx context.Context, dir, rel string) error { - return c.Call(ctx, "system.OpenFile", nil, dir, rel) -} diff --git a/native/lichclient/pulls.go b/native/lichclient/pulls.go deleted file mode 100644 index 00437059c..000000000 --- a/native/lichclient/pulls.go +++ /dev/null @@ -1,329 +0,0 @@ -package lichclient - -import "context" - -// PRChangedEvent is project.PRChangedEvent: a pull request write through lich -// landed on GitHub. -const PRChangedEvent = "pull-request-changed" - -// PRChanged mirrors project.PRChanged. -type PRChanged struct { - Path string `json:"path"` - Number int `json:"number"` -} - -// ChecksRollup mirrors project.ChecksRollup. -type ChecksRollup struct { - Passed int `json:"passed"` - Failed int `json:"failed"` - Pending int `json:"pending"` - Total int `json:"total"` -} - -// CheckItem mirrors project.CheckItem; State is passed|failed|pending. -type CheckItem struct { - Name string `json:"name"` - State string `json:"state"` - Description string `json:"description"` - URL string `json:"url"` - StartedAt string `json:"startedAt"` - CompletedAt string `json:"completedAt"` - Required bool `json:"required"` -} - -// ThreadTally mirrors project.ThreadTally. -type ThreadTally struct { - Open int `json:"open"` - Total int `json:"total"` -} - -// PRSummary mirrors project.PRSummary: one row of the list. -type PRSummary struct { - Number int `json:"number"` - Title string `json:"title"` - Author string `json:"author"` - State string `json:"state"` - IsDraft bool `json:"isDraft"` - ReviewDecision string `json:"reviewDecision"` - HeadRefName string `json:"headRefName"` - IsCrossRepository bool `json:"isCrossRepository"` - UpdatedAt string `json:"updatedAt"` - Checks ChecksRollup `json:"checks"` - Threads ThreadTally `json:"threads"` -} - -// PRReviewer mirrors project.PRReviewer. -type PRReviewer struct { - Login string `json:"login"` - IsTeam bool `json:"isTeam"` - State string `json:"state"` -} - -// PRCommit mirrors project.PRCommit. -type PRCommit struct { - OID string `json:"oid"` - Headline string `json:"headline"` - Body string `json:"body"` - Login string `json:"login"` - Name string `json:"name"` - Email string `json:"email"` - Date string `json:"date"` -} - -// PRDetail mirrors project.PRDetail. -type PRDetail struct { - Number int `json:"number"` - URL string `json:"url"` - Title string `json:"title"` - Body string `json:"body"` - Author string `json:"author"` - AuthorLogin string `json:"authorLogin"` - State string `json:"state"` - IsDraft bool `json:"isDraft"` - Mergeable string `json:"mergeable"` - MergeStateStatus string `json:"mergeStateStatus"` - ReviewDecision string `json:"reviewDecision"` - Reviewers []PRReviewer `json:"reviewers"` - BaseRefName string `json:"baseRefName"` - HeadRefName string `json:"headRefName"` - ChangedFiles int `json:"changedFiles"` - Checks ChecksRollup `json:"checks"` - CheckRuns []CheckItem `json:"checkRuns"` - Commits []PRCommit `json:"commits"` - IsCrossRepository bool `json:"isCrossRepository"` - MaintainerCanModify bool `json:"maintainerCanModify"` -} - -// CommitPattern mirrors project.CommitPattern. -type CommitPattern struct { - Target string `json:"target"` - Operator string `json:"operator"` - Pattern string `json:"pattern"` - Negate bool `json:"negate"` -} - -// BranchRules mirrors project.BranchRules. -type BranchRules struct { - AllowedMergeMethods []string `json:"allowedMergeMethods"` - CommitPatterns []CommitPattern `json:"commitPatterns"` - ViewerCanBypass bool `json:"viewerCanBypass"` -} - -// ListPullRequests reads the repository's pull requests in state -// (open|closed|merged|all) through gh, at most the backend's page of them. -func (c *Client) ListPullRequests(ctx context.Context, path, state string) ([]PRSummary, error) { - var list []PRSummary - return list, c.Call(ctx, "project.ListPullRequests", &list, path, state) -} - -// PullRequestDetail reads pull request number; 0 is the PR of path's branch, -// nil when it has none. -func (c *Client) PullRequestDetail(ctx context.Context, path string, number int) (*PRDetail, error) { - var d *PRDetail - return d, c.Call(ctx, "project.PullRequestDetail", &d, path, number) -} - -// PullRequestConflicts works out which files conflict with base; it fetches -// both refs first. -func (c *Client) PullRequestConflicts(ctx context.Context, path string, number int, base, url string) ([]string, error) { - var files []string - return files, c.Call(ctx, "project.PullRequestConflicts", &files, path, number, base, url) -} - -func (c *Client) BranchRules(ctx context.Context, path, branch string) (*BranchRules, error) { - var r *BranchRules - return r, c.Call(ctx, "project.BranchRules", &r, path, branch) -} - -// MergePullRequest merges with method (squash|merge|rebase); an empty subject -// keeps gh's message, admin bypasses the base branch's rules. -func (c *Client) MergePullRequest(ctx context.Context, path string, number int, method, subject, body string, admin bool) error { - return c.Call(ctx, "project.MergePullRequest", nil, path, number, method, subject, body, admin) -} - -// CreatePullRequest opens GitHub's new pull request page for path's branch. -func (c *Client) CreatePullRequest(ctx context.Context, path string) error { - return c.Call(ctx, "project.CreatePullRequest", nil, path) -} - -// CreateWorktreeFromPR checks pull request number out into a new worktree. -func (c *Client) CreateWorktreeFromPR(ctx context.Context, projectPath, projectID string, number int) (*Worktree, error) { - var wt *Worktree - return wt, c.Call(ctx, "project.CreateWorktreeFromPR", &wt, projectPath, projectID, number) -} - -// ListCheckouts is every checkout of path's repository holding a branch, the -// project's own directory included; Name is the branch. -func (c *Client) ListCheckouts(ctx context.Context, path string) ([]Worktree, error) { - var list []Worktree - return list, c.Call(ctx, "project.ListCheckouts", &list, path) -} - -func (c *Client) WorktreeAdopted(ctx context.Context, path string) (bool, error) { - var ok bool - return ok, c.Call(ctx, "project.WorktreeAdopted", &ok, path) -} - -func (c *Client) WorktreeDirty(ctx context.Context, path string) (bool, error) { - var dirty bool - return dirty, c.Call(ctx, "project.WorktreeDirty", &dirty, path) -} - -// RemoveWorktree removes the checkout at path; git refuses a dirty one -// without force, and an adopted one is refused without adoptedAck. -func (c *Client) RemoveWorktree(ctx context.Context, projectPath, path string, force, adoptedAck bool) error { - return c.Call(ctx, "project.RemoveWorktree", nil, projectPath, path, force, adoptedAck) -} - -// PurgeWorktreeSessions drops every stored session rooted in path. -func (c *Client) PurgeWorktreeSessions(ctx context.Context, projectID, path string) error { - return c.Call(ctx, "store.PurgeWorktreeSessions", nil, projectID, path) -} - -// AnnounceMerge tells the agents in checkout that its pull request merged. -func (c *Client) AnnounceMerge(ctx context.Context, checkout string, number int, title, branch, base string) error { - return c.Call(ctx, "relay.AnnounceMerge", nil, checkout, number, title, branch, base) -} - -// Submit types message at session id's prompt and sends it; the session has to -// be a running agent. -func (c *Client) Submit(ctx context.Context, id, message string) error { - return c.Call(ctx, "relay.Submit", nil, id, message) -} - -// ReviewComment mirrors project.ReviewComment: one line comment of a review. -type ReviewComment struct { - Path string `json:"path"` - Line int `json:"line"` - StartLine int `json:"startLine"` - Side string `json:"side"` - Body string `json:"body"` -} - -// SubmitReview sends a review: event is approve|comment|request_changes, -// head the commit the comments were written against ("" lets GitHub assume -// its latest). -func (c *Client) SubmitReview(ctx context.Context, path string, number int, event, body, head string, comments []ReviewComment) error { - return c.Call(ctx, "project.SubmitReview", nil, path, number, event, body, head, comments) -} - -// PullRequestDiff is the unified diff pull request number would merge, as -// GitHub computes it. -func (c *Client) PullRequestDiff(ctx context.Context, path string, number int) (string, error) { - var diff string - return diff, c.Call(ctx, "project.PullRequestDiff", &diff, path, number) -} - -// PullRequestMergeBase is the commit pull request number's diff is taken -// from: the old side of every file in it. -func (c *Client) PullRequestMergeBase(ctx context.Context, path string, number int) (string, error) { - var oid string - return oid, c.Call(ctx, "project.PullRequestMergeBase", &oid, path, number) -} - -// PRConversation mirrors project.PRConversation. -type PRConversation struct { - HeadRefOID string `json:"headRefOid"` - Reviews []PRReview `json:"reviews"` - Comments []PRComment `json:"comments"` - Threads []PRReviewThread `json:"threads"` -} - -// PRReview mirrors project.PRReview. -type PRReview struct { - ID string `json:"id"` - DatabaseID int64 `json:"databaseId"` - Author string `json:"author"` - State string `json:"state"` - Body string `json:"body"` - Date string `json:"date"` - CanEdit bool `json:"canEdit"` -} - -// PRComment mirrors project.PRComment. -type PRComment struct { - ID int64 `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - Date string `json:"date"` - CanEdit bool `json:"canEdit"` - CanDelete bool `json:"canDelete"` -} - -// PRReviewThread mirrors project.PRReviewThread. -type PRReviewThread struct { - ID string `json:"id"` - Path string `json:"path"` - Line int `json:"line"` - StartLine int `json:"startLine"` - Side string `json:"side"` - IsResolved bool `json:"isResolved"` - IsOutdated bool `json:"isOutdated"` - Comments []PRThreadComment `json:"comments"` -} - -// PRThreadComment mirrors project.PRThreadComment. -type PRThreadComment struct { - ID int64 `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - Date string `json:"date"` - DiffHunk string `json:"diffHunk"` - CanEdit bool `json:"canEdit"` - CanDelete bool `json:"canDelete"` -} - -func (c *Client) PullRequestConversation(ctx context.Context, path string, number int) (*PRConversation, error) { - var conv *PRConversation - return conv, c.Call(ctx, "project.PullRequestConversation", &conv, path, number) -} - -// ReplyToReviewThread answers the thread comment commentID belongs to. -func (c *Client) ReplyToReviewThread(ctx context.Context, path string, number int, commentID int64, body string) error { - return c.Call(ctx, "project.ReplyToReviewThread", nil, path, number, commentID, body) -} - -// EditReviewComment rewrites thread comment commentID. -func (c *Client) EditReviewComment(ctx context.Context, path string, number int, commentID int64, body string) error { - return c.Call(ctx, "project.EditReviewComment", nil, path, number, commentID, body) -} - -// DeleteReviewComment removes thread comment commentID. -func (c *Client) DeleteReviewComment(ctx context.Context, path string, number int, commentID int64) error { - return c.Call(ctx, "project.DeleteReviewComment", nil, path, number, commentID) -} - -// EditReviewBody rewrites the summary of review reviewID (its database id). -func (c *Client) EditReviewBody(ctx context.Context, path string, number int, reviewID int64, body string) error { - return c.Call(ctx, "project.EditReviewBody", nil, path, number, reviewID, body) -} - -// ResolveReviewThread resolves thread threadID, or reopens it. -func (c *Client) ResolveReviewThread(ctx context.Context, path, threadID string, resolved bool) error { - return c.Call(ctx, "project.ResolveReviewThread", nil, path, threadID, resolved) -} - -// CommentOnPullRequest comments on the pull request itself. -func (c *Client) CommentOnPullRequest(ctx context.Context, path string, number int, body string) error { - return c.Call(ctx, "project.CommentOnPullRequest", nil, path, number, body) -} - -// EditPullRequestComment rewrites pull request comment commentID. -func (c *Client) EditPullRequestComment(ctx context.Context, path string, number int, commentID int64, body string) error { - return c.Call(ctx, "project.EditPullRequestComment", nil, path, number, commentID, body) -} - -// DeletePullRequestComment removes pull request comment commentID. -func (c *Client) DeletePullRequestComment(ctx context.Context, path string, number int, commentID int64) error { - return c.Call(ctx, "project.DeletePullRequestComment", nil, path, number, commentID) -} - -// EditPullRequestTitle renames the pull request. -func (c *Client) EditPullRequestTitle(ctx context.Context, path string, number int, title string) error { - return c.Call(ctx, "project.EditPullRequestTitle", nil, path, number, title) -} - -// EditPullRequestBody rewrites the pull request's description; empty clears it. -func (c *Client) EditPullRequestBody(ctx context.Context, path string, number int, body string) error { - return c.Call(ctx, "project.EditPullRequestBody", nil, path, number, body) -} diff --git a/native/lichclient/themes.go b/native/lichclient/themes.go deleted file mode 100644 index 08f54ec20..000000000 --- a/native/lichclient/themes.go +++ /dev/null @@ -1,157 +0,0 @@ -package lichclient - -import ( - "context" - "encoding/json" -) - -// Theme mirrors themes.Theme: a color theme for the app tokens (CSS colors, -// keyed by token name without "--") and the terminal (hex colors, keyed by -// color name). -type Theme struct { - ID string `json:"id"` - Name string `json:"name"` - Scheme string `json:"scheme"` - Origin string `json:"origin"` - Source *ThemeSource `json:"source,omitempty"` - App map[string]string `json:"app"` - Terminal map[string]string `json:"terminal"` - // Syntax styles the code views by highlighting tag; nil keeps the - // default palette of the theme's scheme. - Syntax map[string]SyntaxStyle `json:"syntax,omitempty"` - // Pair is the id of this theme's variant for the other scheme, "" for - // none. - Pair string `json:"pair,omitempty"` -} - -// ThemeSelectionEvent is themes.SelectionEventName: the stored selection -// changed, from this window, another, or `lich theme switch`. -const ThemeSelectionEvent = "theme-selection" - -// ThemeSelection mirrors themes.Selection. -type ThemeSelection struct { - Theme string `json:"theme"` - FollowSystem bool `json:"followSystem"` -} - -// ThemeSelection reads the stored selection; a theme never chosen reads -// "system". -func (c *Client) ThemeSelection(ctx context.Context) (ThemeSelection, error) { - var out ThemeSelection - return out, c.Call(ctx, "themes.Selection", &out) -} - -// SelectTheme stores id, "system" or an installed theme, as the selection. -func (c *Client) SelectTheme(ctx context.Context, id string) error { - return c.Call(ctx, "themes.Select", nil, struct { - ID string `json:"id"` - }{id}) -} - -// SetFollowSystem stores whether the window draws the selected theme's pair -// while the OS asks for the pair's scheme. -func (c *Client) SetFollowSystem(ctx context.Context, on bool) error { - return c.Call(ctx, "themes.SetFollowSystem", nil, on) -} - -// SyntaxStyle mirrors themes.SyntaxStyle: one tag's color and font style. -type SyntaxStyle struct { - Color string `json:"color,omitempty"` - Bold bool `json:"bold,omitempty"` - Italic bool `json:"italic,omitempty"` - Underline bool `json:"underline,omitempty"` - Strikethrough bool `json:"strikethrough,omitempty"` -} - -// UnmarshalJSON also reads the bare color a theme file may write: the -// backend sends the object, the bundled theme files keep the short form. -func (s *SyntaxStyle) UnmarshalJSON(data []byte) error { - if json.Unmarshal(data, &s.Color) == nil { - return nil - } - type plain SyntaxStyle - return json.Unmarshal(data, (*plain)(s)) -} - -// ThemeSource mirrors themes.Source: the repository a theme was installed -// from. -type ThemeSource struct { - URL string `json:"url"` - Version string `json:"version"` -} - -// BrokenTheme mirrors themes.BrokenTheme: a stored theme that cannot load. -type BrokenTheme struct { - ID string `json:"id"` - Reason string `json:"reason"` -} - -// ThemeImportResult mirrors themes.ImportResult: NeedsOverwrite asks before -// replacing a theme of the same id. -type ThemeImportResult struct { - Theme Theme `json:"theme"` - NeedsOverwrite bool `json:"needsOverwrite"` -} - -// ThemeGitResult mirrors themes.GitInstallResult. Conflicts names the ids an -// install refused until it is repeated with overwrite. -type ThemeGitResult struct { - Pack string `json:"pack"` - Version string `json:"version"` - Themes []Theme `json:"themes"` - Conflicts []string `json:"conflicts"` - UpToDate bool `json:"upToDate"` -} - -// Themes returns the bundled themes and the imported ones that load. -func (c *Client) Themes(ctx context.Context) ([]Theme, error) { - var out []Theme - return out, c.Call(ctx, "themes.List", &out) -} - -// BrokenThemes returns the stored themes Themes leaves out, with why. -func (c *Client) BrokenThemes(ctx context.Context) ([]BrokenTheme, error) { - var out []BrokenTheme - return out, c.Call(ctx, "themes.ListBroken", &out) -} - -// ImportTheme imports the theme file at path. -func (c *Client) ImportTheme(ctx context.Context, path string, overwrite bool) (ThemeImportResult, error) { - var out ThemeImportResult - return out, c.Call(ctx, "themes.Import", &out, path, overwrite) -} - -// InstallThemes clones url and installs every theme its manifest lists. -func (c *Client) InstallThemes(ctx context.Context, url string, overwrite bool) (ThemeGitResult, error) { - var out ThemeGitResult - return out, c.Call(ctx, "themes.InstallFromGit", &out, url, overwrite) -} - -// UpdateTheme re-clones the repository theme id came from. -func (c *Client) UpdateTheme(ctx context.Context, id string) (ThemeGitResult, error) { - var out ThemeGitResult - return out, c.Call(ctx, "themes.UpdateFromGit", &out, id) -} - -// RemoveTheme deletes an imported theme; the backend refuses a bundled one. -func (c *Client) RemoveTheme(ctx context.Context, id string) error { - return c.Call(ctx, "themes.Remove", nil, id) -} - -// SaveThemeTemplate writes the starter theme to path. -func (c *Client) SaveThemeTemplate(ctx context.Context, path string) error { - return c.Call(ctx, "themes.SaveTemplate", nil, path) -} - -// PickFile opens the backend's file picker; "" is a cancelled one. -func (c *Client) PickFile(ctx context.Context, title string) (string, error) { - var path string - return path, c.Call(ctx, "project.PickFile", &path, title) -} - -// PickSaveFile opens the backend's save picker on defaultName; "" is a -// cancelled one. -func (c *Client) PickSaveFile(ctx context.Context, title, defaultName string) (string, error) { - var path string - return path, c.Call(ctx, "project.PickSaveFile", &path, title, defaultName) -} diff --git a/native/lichclient/turnstates.go b/native/lichclient/turnstates.go deleted file mode 100644 index 7f72537a0..000000000 --- a/native/lichclient/turnstates.go +++ /dev/null @@ -1,30 +0,0 @@ -package lichclient - -import ( - "context" - "encoding/json" -) - -// TurnState is internal/terminal's TurnState: a live session's last -// session-status report and the session-usage payload behind its footer, both -// raw as the /events stream would have carried them. State is empty for a -// session that never reported; Since is when it entered State, in Unix -// milliseconds. Cwd and Host are its last session-cwd report. -type TurnState struct { - ID string `json:"id"` - State string `json:"state"` - Tool string `json:"tool"` - Detail string `json:"detail"` - Reason string `json:"reason"` - Since int64 `json:"since"` - Usage json.RawMessage `json:"usage"` - Cwd string `json:"cwd"` - Host string `json:"host"` -} - -// TurnStates is what each live session last reported, the catch-up for the -// events /events dropped while no window was attached. -func (c *Client) TurnStates(ctx context.Context) ([]TurnState, error) { - var states []TurnState - return states, c.Call(ctx, "terminal.TurnStates", &states) -} diff --git a/native/lichclient/update.go b/native/lichclient/update.go deleted file mode 100644 index 4658cbd17..000000000 --- a/native/lichclient/update.go +++ /dev/null @@ -1,105 +0,0 @@ -package lichclient - -import "context" - -// AppUpdateStatus mirrors appupdate.Status: whether a newer lich exists. A -// failed release lookup is an answer with everything empty, not an error. -type AppUpdateStatus struct { - CurrentVersion string `json:"currentVersion"` - LatestVersion string `json:"latestVersion"` - UpdateAvailable bool `json:"updateAvailable"` - CanSelfApply bool `json:"canSelfApply"` - ReleaseURL string `json:"releaseUrl"` - InstallCommand string `json:"installCommand"` -} - -// PluginStatus mirrors agentplugin.Status: one provider CLI's lich plugin. -type PluginStatus struct { - Provider string `json:"provider"` - Name string `json:"name"` - Available bool `json:"available"` - Installed bool `json:"installed"` - InstalledVersion string `json:"installedVersion"` - LatestVersion string `json:"latestVersion"` - UpdateAvailable bool `json:"updateAvailable"` - Compatible bool `json:"compatible"` -} - -// PluginIncompatibleEvent is raised when a session's hooks report from a -// lich plugin release outside the range this lich speaks. -const PluginIncompatibleEvent = "plugin-incompatible" - -// PluginProfilesEvent is the event a pass over a provider's profiles reports -// on once Personal's lich plugin moved (agentplugin.ProfilesEventName). -const PluginProfilesEvent = "lich-plugin-profiles" - -// PluginProfiles mirrors agentplugin.ProfilesEvent: the release a pass put -// into a provider's profiles and the ones it could not. Name is the provider's -// display name. -type PluginProfiles struct { - Provider string `json:"provider"` - Name string `json:"name"` - Version string `json:"version"` - Failures []PluginProfileFailure `json:"failures"` -} - -// PluginProfileFailure mirrors agentplugin.ProfileFailure. -type PluginProfileFailure struct { - Profile string `json:"profile"` - Name string `json:"name"` - Error string `json:"error"` -} - -// PatchNotesGroup mirrors patchnotes.Group; its items keep their markdown -// bold and code markers. -type PatchNotesGroup struct { - Label string `json:"label"` - Items []string `json:"items"` -} - -// PatchNotesHighlight mirrors patchnotes.Highlight: Kind is "important", -// "warning" or "note". -type PatchNotesHighlight struct { - Kind string `json:"kind"` - Text string `json:"text"` -} - -// PatchNotes mirrors patchnotes.Notes. Groups is nil, not empty, when no -// changelog section matches the running build. -type PatchNotes struct { - Version string `json:"version"` - Highlights []PatchNotesHighlight `json:"highlights"` - Groups []PatchNotesGroup `json:"groups"` -} - -// AppUpdateStatus looks up the latest release. -func (c *Client) AppUpdateStatus(ctx context.Context) (AppUpdateStatus, error) { - var s AppUpdateStatus - return s, c.Call(ctx, "appupdate.Status", &s) -} - -// PatchNotes returns the running build's changelog section in locale, the -// interface language's tag; the backend falls back to English when the release -// has no translation into it. -func (c *Client) PatchNotes(ctx context.Context, locale string) (PatchNotes, error) { - var n PatchNotes - return n, c.Call(ctx, "patchnotes.Current", &n, locale) -} - -// PluginStatuses lists the lich plugin's state in every provider that can run -// it, in display order. -func (c *Client) PluginStatuses(ctx context.Context) ([]PluginStatus, error) { - var statuses []PluginStatus - return statuses, c.Call(ctx, "agentplugin.Status", &statuses) -} - -// InstallPlugin installs the lich plugin into provider's CLI. -func (c *Client) InstallPlugin(ctx context.Context, provider string) error { - return c.Call(ctx, "agentplugin.Install", nil, provider) -} - -// UpdatePlugin moves provider's installed lich plugin to the release this lich -// is compatible with. -func (c *Client) UpdatePlugin(ctx context.Context, provider string) error { - return c.Call(ctx, "agentplugin.Update", nil, provider) -} diff --git a/native/lichclient/vcs.go b/native/lichclient/vcs.go deleted file mode 100644 index 95a4632bf..000000000 --- a/native/lichclient/vcs.go +++ /dev/null @@ -1,78 +0,0 @@ -package lichclient - -import "context" - -// The Status of a BinaryCheck that passed, and of one with nothing to check; -// every other value is a failure (providers.Check). -const ( - CheckOK = "ok" - CheckEmpty = "empty" -) - -// BinaryCheck mirrors providers.Check: Path is the executable a session would -// spawn, empty for every status but CheckOK. -type BinaryCheck struct { - Path string `json:"path"` - Status string `json:"status"` -} - -// Failed reports a check to show as a problem. -func (b BinaryCheck) Failed() bool { return b.Status != CheckOK && b.Status != CheckEmpty } - -// CommitIdentity mirrors project.CommitIdentity: who the next commit in a -// checkout would be authored as. Every field empty means none is configured. -type CommitIdentity struct { - Name string `json:"name"` - Email string `json:"email"` - Local bool `json:"local"` -} - -// Verify resolves bin as a session's spawn would, through the pinned $PATH. -func (c *Client) Verify(ctx context.Context, bin string) (BinaryCheck, error) { - var b BinaryCheck - return b, c.Call(ctx, "providers.Verify", &b, bin) -} - -// ProviderBinary mirrors providers.Binary: an executable a provider could be -// started from and the version it reports, "" when it printed none. -type ProviderBinary struct { - Path string `json:"path"` - Version string `json:"version"` -} - -// ProviderVersion runs bin, resolved as Verify resolves it, with --version. -// It boots the whole CLI, so it is asked off the frame. -func (c *Client) ProviderVersion(ctx context.Context, bin string) (string, error) { - var v string - return v, c.Call(ctx, "providers.Version", &v, bin) -} - -// ProviderBinaries lists every distinct binary of provider id on the pinned -// $PATH, in $PATH order, each with its version. -func (c *Client) ProviderBinaries(ctx context.Context, id string) ([]ProviderBinary, error) { - var found []ProviderBinary - return found, c.Call(ctx, "providers.OnPath", &found, id) -} - -// RefreshPath re-reads the login shell's $PATH and re-pins it, so the next -// Verify answers from what is installed now. -func (c *Client) RefreshPath(ctx context.Context) error { - return c.Call(ctx, "providers.RefreshPath", nil) -} - -// GitHubAccounts lists gh's signed-in accounts as "/"; empty -// when gh is signed in to none. A round trip to every host gh knows. -func (c *Client) GitHubAccounts(ctx context.Context) ([]string, error) { - var accounts []string - err := c.Call(ctx, "project.GitHubAccounts", &accounts) - if accounts == nil { - accounts = []string{} - } - return accounts, err -} - -// CommitIdentity returns who commits in the checkout at path land as. -func (c *Client) CommitIdentity(ctx context.Context, path string) (CommitIdentity, error) { - var id CommitIdentity - return id, c.Call(ctx, "project.CommitIdentity", &id, path) -} diff --git a/native/lichclient/worktree.go b/native/lichclient/worktree.go deleted file mode 100644 index d15269d34..000000000 --- a/native/lichclient/worktree.go +++ /dev/null @@ -1,82 +0,0 @@ -package lichclient - -import "context" - -// Worktree mirrors project.Worktree. -type Worktree struct { - Name string `json:"name"` - Path string `json:"path"` - Reused bool `json:"reused,omitempty"` -} - -// Branches mirrors project.Branches: what a new worktree can start from. -type Branches struct { - Local []string `json:"local"` - Remote []string `json:"remote"` // "origin/main" form - Worktrees []Worktree `json:"worktrees"` -} - -// BaseFreshness mirrors project.BaseFreshness. -type BaseFreshness struct { - Upstream string `json:"upstream"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` - FetchError string `json:"fetchError,omitempty"` -} - -// Issue mirrors project.Issue. -type Issue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - URL string `json:"url"` -} - -func (c *Client) ListBranches(ctx context.Context, path string) (Branches, error) { - var b Branches - return b, c.Call(ctx, "project.ListBranches", &b, path) -} - -// BaseFreshness fetches branch's upstream first, so it waits on the network -// for up to the backend's fetch budget. -func (c *Client) BaseFreshness(ctx context.Context, path, branch string) (BaseFreshness, error) { - var f BaseFreshness - return f, c.Call(ctx, "project.BaseFreshness", &f, path, branch) -} - -// CreateWorktree checks a new branch name out off base; a remote base is -// fetched first. -func (c *Client) CreateWorktree(ctx context.Context, projectPath, projectID, name, base string, baseIsRemote bool) (*Worktree, error) { - var wt *Worktree - return wt, c.Call(ctx, "project.CreateWorktree", &wt, projectPath, projectID, name, base, baseIsRemote) -} - -// CarryUncommitted copies src's uncommitted work onto dst. -func (c *Client) CarryUncommitted(ctx context.Context, src, dst string) error { - return c.Call(ctx, "project.CarryUncommitted", nil, src, dst) -} - -// Issue resolves a GitHub issue through gh. -func (c *Client) Issue(ctx context.Context, path string, number int) (Issue, error) { - var i Issue - return i, c.Call(ctx, "project.Issue", &i, path, number) -} - -// ReopenWorktreeSession brings back the session parked on path under -// newSessionID, nil when none is parked there. -func (c *Client) ReopenWorktreeSession(ctx context.Context, projectID, path, newSessionID string) (*Session, error) { - var s *Session - return s, c.Call(ctx, "store.ReopenWorktreeSession", &s, projectID, path, newSessionID) -} - -// AddSessionWithSandbox is AddSession with the sandbox answer the session was -// opened with: "on", "off", or "" to leave it to the provider's rung. -func (c *Client) AddSessionWithSandbox(ctx context.Context, projectID, sessionID, label, kind, path string, nextSeq int, sandbox string) error { - return c.Call(ctx, "store.AddSession", nil, projectID, sessionID, label, kind, path, nextSeq, sandbox) -} - -// Ready reports whether session id sits at a prompt that can take input. -func (c *Client) Ready(ctx context.Context, id string) (bool, error) { - var ok bool - return ok, c.Call(ctx, "terminal.Ready", &ok, id) -} diff --git a/native/lichdiff/buttons.go b/native/lichdiff/buttons.go index af386dc15..e578ac22a 100644 --- a/native/lichdiff/buttons.go +++ b/native/lichdiff/buttons.go @@ -18,7 +18,7 @@ import ( "golang.org/x/exp/shiny/materialdesign/icons" "github.com/lichdotdev/godemirror" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // The buttons lich draws inside its diff, to expand and to revert, in its dark @@ -42,7 +42,7 @@ const ( // Options builds the DiffOptions of lich's diff: its buttons, drawn with th, // expand asking the backend for a gap's lines and revert putting a chunk's // lines back through it. -func Options(th *material.Theme, expand func(godemirror.Gap), revert func([]lichclient.RevertLine)) (godemirror.DiffOptions, error) { +func Options(th *material.Theme, expand func(godemirror.Gap), revert func([]backend.RevertLine)) (godemirror.DiffOptions, error) { b, err := newButtons(th) if err != nil { return godemirror.DiffOptions{}, err @@ -58,14 +58,14 @@ func Options(th *material.Theme, expand func(godemirror.Gap), revert func([]lich // RevertLines names a chunk's changed lines the way project.RevertLines takes // them: "old" for a deletion, numbered in HEAD, "new" for an addition. -func RevertLines(c godemirror.Chunk) []lichclient.RevertLine { - lines := make([]lichclient.RevertLine, len(c.Lines)) +func RevertLines(c godemirror.Chunk) []backend.RevertLine { + lines := make([]backend.RevertLine, len(c.Lines)) for i, l := range c.Lines { side := "new" if l.Side == godemirror.OldSide { side = "old" } - lines[i] = lichclient.RevertLine{Side: side, Line: l.Line, Text: l.Text} + lines[i] = backend.RevertLine{Side: side, Line: l.Line, Text: l.Text} } return lines } diff --git a/native/lichdiff/lichdiff_test.go b/native/lichdiff/lichdiff_test.go index ced1aba30..aa2546182 100644 --- a/native/lichdiff/lichdiff_test.go +++ b/native/lichdiff/lichdiff_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/lichdotdev/godemirror" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestRevertLinesNamesSidesAsTheBackendTakesThem(t *testing.T) { @@ -13,7 +13,7 @@ func TestRevertLinesNamesSidesAsTheBackendTakesThem(t *testing.T) { {Side: godemirror.OldSide, Line: 4, Text: "b"}, {Side: godemirror.NewSide, Line: 4, Text: "B"}, }} - want := []lichclient.RevertLine{{Side: "old", Line: 4, Text: "b"}, {Side: "new", Line: 4, Text: "B"}} + want := []backend.RevertLine{{Side: "old", Line: 4, Text: "b"}, {Side: "new", Line: 4, Text: "B"}} if got := RevertLines(c); !slices.Equal(got, want) { t.Fatalf("got %+v, want %+v", got, want) }