From 09f51f9a1192ca54ecac7cbf5018288eaa2b0019 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 22 Oct 2026 23:54:26 -0300 Subject: [PATCH 01/13] refactor(native): rename lichclient package to backend --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- docs/adding-a-provider.md | 2 +- docs/ceilings.md | 4 +- native/{lichclient => backend}/api.go | 2 +- native/{lichclient => backend}/appupdate.go | 2 +- .../{lichclient => backend}/appupdate_test.go | 2 +- native/{lichclient => backend}/blob.go | 2 +- native/{lichclient => backend}/blob_test.go | 2 +- native/{lichclient => backend}/client.go | 4 +- native/{lichclient => backend}/client_test.go | 2 +- native/{lichclient => backend}/drop.go | 2 +- native/{lichclient => backend}/env.go | 2 +- native/{lichclient => backend}/env_test.go | 2 +- native/{lichclient => backend}/footer.go | 2 +- native/{lichclient => backend}/footer_test.go | 2 +- native/{lichclient => backend}/live_test.go | 2 +- native/{lichclient => backend}/palette.go | 2 +- .../{lichclient => backend}/palette_test.go | 2 +- native/{lichclient => backend}/profile.go | 2 +- .../{lichclient => backend}/profile_test.go | 2 +- native/{lichclient => backend}/project.go | 2 +- native/{lichclient => backend}/projects.go | 2 +- native/{lichclient => backend}/pulls.go | 2 +- native/{lichclient => backend}/session.go | 2 +- native/{lichclient => backend}/store.go | 2 +- native/{lichclient => backend}/store_test.go | 2 +- native/{lichclient => backend}/themes.go | 2 +- native/{lichclient => backend}/turnstates.go | 2 +- .../turnstates_test.go | 2 +- native/{lichclient => backend}/update.go | 2 +- native/{lichclient => backend}/update_test.go | 2 +- native/{lichclient => backend}/vcs.go | 2 +- native/{lichclient => backend}/vcs_test.go | 2 +- native/{lichclient => backend}/window.go | 2 +- native/{lichclient => backend}/worktree.go | 2 +- native/cmd/lich-native/app_update_gate.go | 26 ++-- .../cmd/lich-native/app_update_gate_test.go | 26 ++-- native/cmd/lich-native/appearance.go | 10 +- native/cmd/lich-native/appearance_prefs.go | 10 +- native/cmd/lich-native/attention_test.go | 8 +- native/cmd/lich-native/binary_state.go | 8 +- native/cmd/lich-native/blob_preview.go | 10 +- native/cmd/lich-native/cardmenu_test.go | 10 +- native/cmd/lich-native/cardstate.go | 6 +- native/cmd/lich-native/cardstate_test.go | 10 +- native/cmd/lich-native/close_undo.go | 16 +-- native/cmd/lich-native/close_undo_test.go | 18 +-- native/cmd/lich-native/close_worktree.go | 16 +-- native/cmd/lich-native/close_worktree_test.go | 4 +- native/cmd/lich-native/closed_projects.go | 8 +- native/cmd/lich-native/desktop_notes.go | 4 +- native/cmd/lich-native/dock.go | 8 +- native/cmd/lich-native/dock_code.go | 8 +- native/cmd/lich-native/dock_code_markdown.go | 4 +- native/cmd/lich-native/dock_code_preview.go | 8 +- native/cmd/lich-native/dock_inject.go | 4 +- native/cmd/lich-native/dock_review.go | 18 +-- native/cmd/lich-native/dock_review_binary.go | 4 +- native/cmd/lich-native/dock_review_body.go | 4 +- .../cmd/lich-native/dock_review_markdown.go | 8 +- native/cmd/lich-native/dock_review_source.go | 4 +- native/cmd/lich-native/dock_review_test.go | 12 +- native/cmd/lich-native/dock_search_view.go | 4 +- native/cmd/lich-native/duplicate_test.go | 8 +- native/cmd/lich-native/exit_toast_test.go | 18 +-- native/cmd/lich-native/footer.go | 8 +- native/cmd/lich-native/footer_attach.go | 4 +- native/cmd/lich-native/footer_editor.go | 4 +- native/cmd/lich-native/footer_layout.go | 6 +- native/cmd/lich-native/footer_layout_test.go | 6 +- native/cmd/lich-native/footer_leaves.go | 4 +- native/cmd/lich-native/footer_plan.go | 4 +- native/cmd/lich-native/footer_quota.go | 50 ++++---- native/cmd/lich-native/footer_readout_test.go | 46 +++---- native/cmd/lich-native/footer_slots.go | 10 +- native/cmd/lich-native/footer_stores_test.go | 50 ++++---- native/cmd/lich-native/footer_test.go | 8 +- native/cmd/lich-native/footer_usage.go | 4 +- native/cmd/lich-native/fork.go | 6 +- native/cmd/lich-native/fork_actions.go | 12 +- native/cmd/lich-native/gates_click_test.go | 20 +-- native/cmd/lich-native/gitstate.go | 8 +- native/cmd/lich-native/gitstate_test.go | 32 ++--- native/cmd/lich-native/home_test.go | 10 +- native/cmd/lich-native/i18n_shell_test.go | 6 +- native/cmd/lich-native/main.go | 24 ++-- native/cmd/lich-native/model.go | 116 +++++++++--------- native/cmd/lich-native/model_events.go | 10 +- native/cmd/lich-native/model_layouts_test.go | 10 +- native/cmd/lich-native/model_test.go | 54 ++++---- native/cmd/lich-native/notify.go | 6 +- native/cmd/lich-native/notify_test.go | 4 +- native/cmd/lich-native/palette.go | 14 +-- native/cmd/lich-native/palette_logic.go | 26 ++-- native/cmd/lich-native/palette_logic_test.go | 30 ++--- native/cmd/lich-native/palette_test.go | 4 +- native/cmd/lich-native/palette_view.go | 4 +- native/cmd/lich-native/palette_window.go | 16 +-- native/cmd/lich-native/paste_unfold.go | 4 +- native/cmd/lich-native/patch_notes_gate.go | 4 +- .../cmd/lich-native/patch_notes_gate_test.go | 10 +- native/cmd/lich-native/plugin_gate.go | 26 ++-- native/cmd/lich-native/plugin_gate_test.go | 28 ++--- native/cmd/lich-native/profile_avatar.go | 4 +- native/cmd/lich-native/profiles.go | 46 +++---- native/cmd/lich-native/profiles_test.go | 18 +-- native/cmd/lich-native/profiles_xdg.go | 6 +- .../lich-native/profiles_xdg_linux_test.go | 4 +- native/cmd/lich-native/project_events_test.go | 8 +- native/cmd/lich-native/projects.go | 30 ++--- native/cmd/lich-native/projects_test.go | 22 ++-- native/cmd/lich-native/providers_book.go | 14 +-- native/cmd/lich-native/providers_book_test.go | 16 +-- native/cmd/lich-native/pulls.go | 12 +- native/cmd/lich-native/pulls_actions.go | 44 +++---- native/cmd/lich-native/pulls_comment_edit.go | 10 +- native/cmd/lich-native/pulls_conversation.go | 20 +-- .../lich-native/pulls_conversation_test.go | 22 ++-- native/cmd/lich-native/pulls_edit.go | 20 +-- native/cmd/lich-native/pulls_edit_test.go | 16 +-- native/cmd/lich-native/pulls_files.go | 10 +- native/cmd/lich-native/pulls_i18n_test.go | 10 +- native/cmd/lich-native/pulls_list.go | 18 +-- native/cmd/lich-native/pulls_list_test.go | 18 +-- native/cmd/lich-native/pulls_logic.go | 38 +++--- native/cmd/lich-native/pulls_logic_test.go | 32 ++--- native/cmd/lich-native/pulls_merge_dialog.go | 10 +- native/cmd/lich-native/pulls_panels.go | 24 ++-- native/cmd/lich-native/pulls_review_logic.go | 24 ++-- .../lich-native/pulls_review_logic_test.go | 14 +-- native/cmd/lich-native/pulls_store.go | 56 ++++----- native/cmd/lich-native/pulls_store_test.go | 26 ++-- native/cmd/lich-native/pulls_submit.go | 16 +-- native/cmd/lich-native/pulls_view.go | 32 ++--- native/cmd/lich-native/resume_dialog.go | 4 +- native/cmd/lich-native/sessiontooltip.go | 4 +- native/cmd/lich-native/settings.go | 8 +- native/cmd/lich-native/settings_editor.go | 4 +- native/cmd/lich-native/settings_help.go | 12 +- native/cmd/lich-native/settings_help_test.go | 4 +- native/cmd/lich-native/settings_language.go | 6 +- native/cmd/lich-native/settings_profiles.go | 42 +++---- .../lich-native/settings_profiles_dialogs.go | 14 +-- .../cmd/lich-native/settings_profiles_look.go | 12 +- .../settings_profiles_look_test.go | 14 +-- .../cmd/lich-native/settings_profiles_shot.go | 16 +-- .../cmd/lich-native/settings_profiles_test.go | 90 +++++++------- .../cmd/lich-native/settings_profiles_view.go | 18 +-- native/cmd/lich-native/settings_providers.go | 16 +-- .../cmd/lich-native/settings_providers_bin.go | 18 +-- .../lich-native/settings_providers_plan.go | 6 +- .../lich-native/settings_providers_test.go | 32 ++--- .../lich-native/settings_providers_version.go | 10 +- .../lich-native/settings_providers_view.go | 4 +- native/cmd/lich-native/settings_sandbox.go | 4 +- native/cmd/lich-native/settings_scope.go | 4 +- native/cmd/lich-native/settings_theme.go | 10 +- .../cmd/lich-native/settings_theme_actions.go | 6 +- native/cmd/lich-native/settings_updates.go | 10 +- .../cmd/lich-native/settings_updates_notes.go | 12 +- .../lich-native/settings_updates_plugin.go | 20 +-- .../cmd/lich-native/settings_updates_test.go | 20 +-- native/cmd/lich-native/settings_vcs.go | 12 +- .../cmd/lich-native/settings_vcs_account.go | 4 +- native/cmd/lich-native/settings_vcs_picker.go | 6 +- native/cmd/lich-native/settings_vcs_test.go | 10 +- native/cmd/lich-native/sidebar_actions.go | 10 +- native/cmd/lich-native/sidebar_blocks.go | 4 +- native/cmd/lich-native/sidebar_blocks_test.go | 8 +- .../cmd/lich-native/sidebar_closing_test.go | 6 +- .../cmd/lich-native/sidebar_compose_test.go | 4 +- native/cmd/lich-native/sidebar_filter.go | 16 +-- native/cmd/lich-native/sidebar_filter_test.go | 4 +- native/cmd/lich-native/sidebar_groups.go | 46 +++---- native/cmd/lich-native/sidebar_groups_test.go | 40 +++--- native/cmd/lich-native/sidebar_i18n_test.go | 6 +- native/cmd/lich-native/sidebar_launch_test.go | 6 +- native/cmd/lich-native/sidebar_prefs.go | 14 +-- native/cmd/lich-native/sidebar_prefs_test.go | 14 +-- native/cmd/lich-native/sidebar_profile.go | 4 +- .../cmd/lich-native/sidebar_profile_footer.go | 6 +- .../sidebar_profile_footer_test.go | 8 +- native/cmd/lich-native/spawn_gate.go | 14 +-- native/cmd/lich-native/spawn_gate_test.go | 10 +- native/cmd/lich-native/stale_dialogs_test.go | 10 +- native/cmd/lich-native/stale_pulls_test.go | 8 +- native/cmd/lich-native/status.go | 6 +- native/cmd/lich-native/status_test.go | 12 +- native/cmd/lich-native/stores_events_test.go | 26 ++-- native/cmd/lich-native/terminal_drop.go | 8 +- native/cmd/lich-native/terminal_drop_test.go | 8 +- native/cmd/lich-native/terminal_exit.go | 4 +- native/cmd/lich-native/terminals.go | 10 +- native/cmd/lich-native/themes.go | 22 ++-- native/cmd/lich-native/themes_test.go | 32 ++--- native/cmd/lich-native/toasts.go | 6 +- native/cmd/lich-native/topbar.go | 4 +- native/cmd/lich-native/topbar_apply.go | 14 +-- native/cmd/lich-native/topbar_menus.go | 8 +- native/cmd/lich-native/tray.go | 8 +- native/cmd/lich-native/turnstates_test.go | 26 ++-- native/cmd/lich-native/window_close.go | 6 +- native/cmd/lich-native/worktree_dialog.go | 54 ++++---- .../cmd/lich-native/worktree_dialog_view.go | 4 +- native/cmd/lich-native/worktree_logic.go | 8 +- native/cmd/lich-native/worktree_logic_test.go | 26 ++-- native/lichdiff/buttons.go | 10 +- native/lichdiff/lichdiff_test.go | 4 +- 209 files changed, 1351 insertions(+), 1351 deletions(-) rename native/{lichclient => backend}/api.go (99%) rename native/{lichclient => backend}/appupdate.go (98%) rename native/{lichclient => backend}/appupdate_test.go (98%) rename native/{lichclient => backend}/blob.go (98%) rename native/{lichclient => backend}/blob_test.go (98%) rename native/{lichclient => backend}/client.go (98%) rename native/{lichclient => backend}/client_test.go (99%) rename native/{lichclient => backend}/drop.go (98%) rename native/{lichclient => backend}/env.go (97%) rename native/{lichclient => backend}/env_test.go (98%) rename native/{lichclient => backend}/footer.go (99%) rename native/{lichclient => backend}/footer_test.go (99%) rename native/{lichclient => backend}/live_test.go (99%) rename native/{lichclient => backend}/palette.go (99%) rename native/{lichclient => backend}/palette_test.go (99%) rename native/{lichclient => backend}/profile.go (99%) rename native/{lichclient => backend}/profile_test.go (99%) rename native/{lichclient => backend}/project.go (99%) rename native/{lichclient => backend}/projects.go (99%) rename native/{lichclient => backend}/pulls.go (99%) rename native/{lichclient => backend}/session.go (99%) rename native/{lichclient => backend}/store.go (99%) rename native/{lichclient => backend}/store_test.go (99%) rename native/{lichclient => backend}/themes.go (99%) rename native/{lichclient => backend}/turnstates.go (98%) rename native/{lichclient => backend}/turnstates_test.go (98%) rename native/{lichclient => backend}/update.go (99%) rename native/{lichclient => backend}/update_test.go (98%) rename native/{lichclient => backend}/vcs.go (99%) rename native/{lichclient => backend}/vcs_test.go (97%) rename native/{lichclient => backend}/window.go (98%) rename native/{lichclient => backend}/worktree.go (99%) diff --git a/CLAUDE.md b/CLAUDE.md index 8acccb6ab..ff61d02e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ 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 +- Service shapes are hand-owned in `native/backend`: touch a Go struct's JSON tags and its mirror there moves in the same change. 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07e7ebe3b..71725e03f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ 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 +- **Touched a Go struct's JSON tags?** `native/backend` mirrors them by hand. There is no codegen, so it moves in the same change. Commit messages follow [Conventional Commits](https://www.conventionalcommits.org) diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md index 74c20a372..2c695f948 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, unless the change moves a JSON tag: the `DetectedProvider` mirror is hand-owned and moves in the same commit | `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..d02e643c2 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -556,7 +556,7 @@ work when nobody knows it and that the call site never shows. The mechanism and `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 + (`native/backend`) 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. - **A release's highlight is read from the binary, so fixing it after the tag fixes nothing a user sees** @@ -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/native/lichclient/api.go b/native/backend/api.go similarity index 99% rename from native/lichclient/api.go rename to native/backend/api.go index 07fde2181..f5c0fedd3 100644 --- a/native/lichclient/api.go +++ b/native/backend/api.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/appupdate.go b/native/backend/appupdate.go similarity index 98% rename from native/lichclient/appupdate.go rename to native/backend/appupdate.go index 4b63542f8..38781820c 100644 --- a/native/lichclient/appupdate.go +++ b/native/backend/appupdate.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" 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 98% rename from native/lichclient/client.go rename to native/backend/client.go index 7eae051b9..ee4620419 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: positional-argument RPC 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" diff --git a/native/lichclient/client_test.go b/native/backend/client_test.go similarity index 99% rename from native/lichclient/client_test.go rename to native/backend/client_test.go index d5c74ac95..16cac8e22 100644 --- a/native/lichclient/client_test.go +++ b/native/backend/client_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "bytes" 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/lichclient/footer.go b/native/backend/footer.go similarity index 99% rename from native/lichclient/footer.go rename to native/backend/footer.go index 628ba5f91..1a03d661e 100644 --- a/native/lichclient/footer.go +++ b/native/backend/footer.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/footer_test.go b/native/backend/footer_test.go similarity index 99% rename from native/lichclient/footer_test.go rename to native/backend/footer_test.go index cb81e2806..e08a0b0c7 100644 --- a/native/lichclient/footer_test.go +++ b/native/backend/footer_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" 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/lichclient/palette.go b/native/backend/palette.go similarity index 99% rename from native/lichclient/palette.go rename to native/backend/palette.go index af0746329..141f4eb76 100644 --- a/native/lichclient/palette.go +++ b/native/backend/palette.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/palette_test.go b/native/backend/palette_test.go similarity index 99% rename from native/lichclient/palette_test.go rename to native/backend/palette_test.go index 9a49f4f44..1ac9fedae 100644 --- a/native/lichclient/palette_test.go +++ b/native/backend/palette_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/profile.go b/native/backend/profile.go similarity index 99% rename from native/lichclient/profile.go rename to native/backend/profile.go index 2e392ec83..9c8630f13 100644 --- a/native/lichclient/profile.go +++ b/native/backend/profile.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/profile_test.go b/native/backend/profile_test.go similarity index 99% rename from native/lichclient/profile_test.go rename to native/backend/profile_test.go index ca9f30631..432112a64 100644 --- a/native/lichclient/profile_test.go +++ b/native/backend/profile_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/project.go b/native/backend/project.go similarity index 99% rename from native/lichclient/project.go rename to native/backend/project.go index 30c3c6251..5935ac0d1 100644 --- a/native/lichclient/project.go +++ b/native/backend/project.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/projects.go b/native/backend/projects.go similarity index 99% rename from native/lichclient/projects.go rename to native/backend/projects.go index 3ef832862..1bc081cd9 100644 --- a/native/lichclient/projects.go +++ b/native/backend/projects.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/pulls.go b/native/backend/pulls.go similarity index 99% rename from native/lichclient/pulls.go rename to native/backend/pulls.go index 00437059c..39e0cb23a 100644 --- a/native/lichclient/pulls.go +++ b/native/backend/pulls.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/session.go b/native/backend/session.go similarity index 99% rename from native/lichclient/session.go rename to native/backend/session.go index d6e14550e..b90449cfe 100644 --- a/native/lichclient/session.go +++ b/native/backend/session.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/store.go b/native/backend/store.go similarity index 99% rename from native/lichclient/store.go rename to native/backend/store.go index 95db36877..3b15296ef 100644 --- a/native/lichclient/store.go +++ b/native/backend/store.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/store_test.go b/native/backend/store_test.go similarity index 99% rename from native/lichclient/store_test.go rename to native/backend/store_test.go index 92024ad26..2e0d5f2df 100644 --- a/native/lichclient/store_test.go +++ b/native/backend/store_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/themes.go b/native/backend/themes.go similarity index 99% rename from native/lichclient/themes.go rename to native/backend/themes.go index 08f54ec20..f8e6d67a3 100644 --- a/native/lichclient/themes.go +++ b/native/backend/themes.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/turnstates.go b/native/backend/turnstates.go similarity index 98% rename from native/lichclient/turnstates.go rename to native/backend/turnstates.go index 7f72537a0..d6cee6be0 100644 --- a/native/lichclient/turnstates.go +++ b/native/backend/turnstates.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/turnstates_test.go b/native/backend/turnstates_test.go similarity index 98% rename from native/lichclient/turnstates_test.go rename to native/backend/turnstates_test.go index e7456de31..773c7017e 100644 --- a/native/lichclient/turnstates_test.go +++ b/native/backend/turnstates_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/update.go b/native/backend/update.go similarity index 99% rename from native/lichclient/update.go rename to native/backend/update.go index 4658cbd17..89a779085 100644 --- a/native/lichclient/update.go +++ b/native/backend/update.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/update_test.go b/native/backend/update_test.go similarity index 98% rename from native/lichclient/update_test.go rename to native/backend/update_test.go index 61f68db22..4f6064a3e 100644 --- a/native/lichclient/update_test.go +++ b/native/backend/update_test.go @@ -1,4 +1,4 @@ -package lichclient +package backend import ( "context" diff --git a/native/lichclient/vcs.go b/native/backend/vcs.go similarity index 99% rename from native/lichclient/vcs.go rename to native/backend/vcs.go index 95a4632bf..9d0458a63 100644 --- a/native/lichclient/vcs.go +++ b/native/backend/vcs.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" 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 98% rename from native/lichclient/window.go rename to native/backend/window.go index 3782c969e..67b44aa7e 100644 --- a/native/lichclient/window.go +++ b/native/backend/window.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" diff --git a/native/lichclient/worktree.go b/native/backend/worktree.go similarity index 99% rename from native/lichclient/worktree.go rename to native/backend/worktree.go index d15269d34..5a754bef3 100644 --- a/native/lichclient/worktree.go +++ b/native/backend/worktree.go @@ -1,4 +1,4 @@ -package lichclient +package backend import "context" 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..fed6a348b 100644 --- a/native/cmd/lich-native/cardstate.go +++ b/native/cmd/lich-native/cardstate.go @@ -6,8 +6,8 @@ import ( "log" "time" + "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 @@ -44,7 +44,7 @@ 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 { +func (m *model) applyStatus(ctx context.Context, ev backend.Event) error { var s struct { ID string `json:"id"` State string `json:"state"` @@ -100,7 +100,7 @@ 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 diff --git a/native/cmd/lich-native/cardstate_test.go b/native/cmd/lich-native/cardstate_test.go index 53da8df63..811852876 100644 --- a/native/cmd/lich-native/cardstate_test.go +++ b/native/cmd/lich-native/cardstate_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) func TestCardWearsTheLiveAgentUntilItLeaves(t *testing.T) { @@ -61,9 +61,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,7 +71,7 @@ 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": @@ -87,7 +87,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 { 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..4bbe79d5c 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" ) @@ -50,7 +50,7 @@ type treeData struct { } type codeTab struct { - client *lichclient.Client + client *backend.Client comments *commentStore notify func(ui.Toast) @@ -76,7 +76,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,7 +165,7 @@ 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) { +func readTree(ctx context.Context, client *backend.Client, path string) (*treeData, error) { var l fileListing if err := client.Call(ctx, "project.Tree", &l, path); err != nil { return nil, err 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..b5e631f31 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 @@ -204,7 +204,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..3cc38b0f1 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)}) + 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++ @@ -379,7 +379,7 @@ func (p *reviewPanel) discard(f patchFile) { 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)})}) + 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..32cb3a373 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")) } @@ -105,10 +105,10 @@ func (c *fileCard) markdownSource(p *reviewPanel) (src string, loading bool, err } // 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..c3f10bb2b 100644 --- a/native/cmd/lich-native/dock_review_test.go +++ b/native/cmd/lich-native/dock_review_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -185,7 +185,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 +204,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,7 +220,7 @@ func TestARevertsUndoPutsTheLinesBack(t *testing.T) { toast.Action.OnClick() select { case args := <-restored: - var gotUndo lichclient.RevertResult + var gotUndo backend.RevertResult _ = json.Unmarshal(args[2], &gotUndo) if string(args[0]) != `"/repo"` || string(args[1]) != `"internal/a.go"` || gotUndo != undo { t.Fatalf("RestoreLines(%s) — want the checkout, the file and the revert's own undo", args) diff --git a/native/cmd/lich-native/dock_search_view.go b/native/cmd/lich-native/dock_search_view.go index 067b81bd3..cfad22aa8 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")) } diff --git a/native/cmd/lich-native/duplicate_test.go b/native/cmd/lich-native/duplicate_test.go index 5d2bbb0c3..0c9ed5621 100644 --- a/native/cmd/lich-native/duplicate_test.go +++ b/native/cmd/lich-native/duplicate_test.go @@ -3,14 +3,14 @@ package main import ( "testing" - "github.com/lichdotdev/lich/native/lichclient" + "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 } diff --git a/native/cmd/lich-native/exit_toast_test.go b/native/cmd/lich-native/exit_toast_test.go index 686e8b92b..6c4b290de 100644 --- a/native/cmd/lich-native/exit_toast_test.go +++ b/native/cmd/lich-native/exit_toast_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" + "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 +34,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 +55,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 +63,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 +83,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 +93,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,7 +147,7 @@ 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") } @@ -162,8 +162,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, backend.SessionConversationEvent, 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..47fef6f39 100644 --- a/native/cmd/lich-native/footer_layout_test.go +++ b/native/cmd/lich-native/footer_layout_test.go @@ -13,7 +13,7 @@ import ( "sync" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // fakeSettings serves store.GetSetting and store.SetSetting from an in-memory @@ -24,7 +24,7 @@ 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 @@ -49,7 +49,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..daee0ec61 100644 --- a/native/cmd/lich-native/footer_quota.go +++ b/native/cmd/lich-native/footer_quota.go @@ -11,8 +11,8 @@ import ( "sync" "time" + "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. @@ -33,7 +33,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 +42,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 +63,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 +75,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 +83,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,9 +140,9 @@ 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) { switch ev.Name { - case sessionLimitEvent, lichclient.SessionProfileEvent, statusEventName: + case sessionLimitEvent, backend.SessionProfileEvent, statusEventName: default: return } @@ -222,10 +222,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 +233,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 +244,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 +262,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 +274,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 +302,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 +320,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..0e6d6ec81 100644 --- a/native/cmd/lich-native/footer_readout_test.go +++ b/native/cmd/lich-native/footer_readout_test.go @@ -14,8 +14,8 @@ import ( "testing" "time" + "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 +171,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 +199,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 +219,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 +233,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 +247,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 +320,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 @@ -345,12 +345,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 +362,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..5e10ed8e5 100644 --- a/native/cmd/lich-native/footer_usage.go +++ b/native/cmd/lich-native/footer_usage.go @@ -6,7 +6,7 @@ import ( "slices" "sync" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // costMisses are the named absences of a cost figure this build has a @@ -67,7 +67,7 @@ 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) { +func (f *footerUsage) apply(ev backend.Event) (bool, error) { var e struct { ID string `json:"id"` Agent *string `json:"agent"` 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..96e06ca61 100644 --- a/native/cmd/lich-native/gates_click_test.go +++ b/native/cmd/lich-native/gates_click_test.go @@ -23,7 +23,7 @@ import ( "gioui.org/widget" "github.com/coder/websocket" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -32,7 +32,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 +51,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,7 +209,7 @@ 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) @@ -240,7 +240,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) }} } @@ -295,7 +295,7 @@ 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"]` { t.Errorf("wrote %s, want 1.4.0 dismissed", args) @@ -304,7 +304,7 @@ func TestUpdateToastLaterRemembersTheRelease(t *testing.T) { 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"]` { t.Errorf("opened %s, want the release page", args) @@ -313,7 +313,7 @@ func TestUpdateToastViewReleaseOpensItsPage(t *testing.T) { 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 @@ -330,7 +330,7 @@ func TestUpdateToastInstallTypesTheCommandIntoANewShell(t *testing.T) { 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,7 +338,7 @@ 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"]` { t.Errorf("updated %s, want claude", 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..7bd16ace6 100644 --- a/native/cmd/lich-native/gitstate_test.go +++ b/native/cmd/lich-native/gitstate_test.go @@ -11,18 +11,18 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "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 +37,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 +48,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 +60,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 +73,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 +103,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 +131,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 +155,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,7 +172,7 @@ 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}) 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..8017eae14 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // gitPoll is how often each shown path's branch and changes are re-read. The @@ -26,11 +26,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 +38,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 +69,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,12 +85,12 @@ 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 @@ -100,7 +100,7 @@ type model struct { // 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 @@ -118,11 +118,11 @@ type sessionCwd struct { Host string `json:"host"` } -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 +163,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 +184,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 +199,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 +217,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 } @@ -261,7 +261,7 @@ 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 { m.mu.Lock() @@ -301,42 +301,42 @@ func (m *model) follow(ctx context.Context, events <-chan lichclient.Event) { if json.Unmarshal(ev.Data, &s) == nil { m.rename(s.ID, s.Label) } - case lichclient.SessionConversationEvent: - var c lichclient.ConversationReported + case backend.SessionConversationEvent: + var c backend.ConversationReported if json.Unmarshal(ev.Data, &c) == nil { m.setConversation(c.ID, c.ProviderSessionID) } - case lichclient.SessionProfileEvent: - var moved lichclient.ProfileMoved + case backend.SessionProfileEvent: + var moved backend.ProfileMoved if json.Unmarshal(ev.Data, &moved) == nil { m.setProfile(moved.ID, moved.Profile) } - case lichclient.ProfileChangedEvent: - var c lichclient.ProfileChanged + case backend.ProfileChangedEvent: + var c backend.ProfileChanged if json.Unmarshal(ev.Data, &c) == nil { m.profileChanged(c) } - case lichclient.ClaudeProfileLoginEvent, lichclient.CodexProfileLoginEvent: - var step lichclient.ProfileLogin + case backend.ClaudeProfileLoginEvent, backend.CodexProfileLoginEvent: + 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 + case backend.PluginProfilesEvent: + var pass backend.PluginProfiles if json.Unmarshal(ev.Data, &pass) == nil && m.pluginProfiles != nil { m.pluginProfiles(pass) } - case lichclient.ThemeSelectionEvent: - var sel lichclient.ThemeSelection + case backend.ThemeSelectionEvent: + var sel backend.ThemeSelection if json.Unmarshal(ev.Data, &sel) == nil && m.themeSelection != nil { m.themeSelection(sel) } - case lichclient.PRChangedEvent: + case backend.PRChangedEvent: if m.prChanged != nil { m.prChanged() } - case lichclient.AppUpdateProgressEvent: - var p lichclient.AppUpdateProgress + case backend.AppUpdateProgressEvent: + var p backend.AppUpdateProgress if json.Unmarshal(ev.Data, &p) == nil && m.appUpdateProgress != nil { m.appUpdateProgress(p) } @@ -345,7 +345,7 @@ func (m *model) follow(ctx context.Context, events <-chan lichclient.Event) { if json.Unmarshal(ev.Data, &d) == nil && m.duplicate != nil { m.duplicate(d) } - case lichclient.PluginIncompatibleEvent: + case backend.PluginIncompatibleEvent: if m.pluginIncompatible != nil { m.pluginIncompatible() } @@ -477,7 +477,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 +485,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 +503,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 +517,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 +532,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 +550,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 +586,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 +621,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 +668,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 +708,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 +732,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..7cfcd6a10 100644 --- a/native/cmd/lich-native/model_events.go +++ b/native/cmd/lich-native/model_events.go @@ -5,7 +5,7 @@ import ( "encoding/json" "slices" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) const ( @@ -86,7 +86,7 @@ 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) } @@ -96,7 +96,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 +111,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 == backend.CodexProfileLoginEvent { 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..f2184c2aa 100644 --- a/native/cmd/lich-native/model_layouts_test.go +++ b/native/cmd/lich-native/model_layouts_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // layoutsBackend answers every call with null and keeps the last value @@ -58,10 +58,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..b4acd6914 100644 --- a/native/cmd/lich-native/model_test.go +++ b/native/cmd/lich-native/model_test.go @@ -11,7 +11,7 @@ import ( "strconv" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) const ( @@ -22,7 +22,7 @@ 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 @@ -39,17 +39,17 @@ func fakeDiff(t *testing.T, notGit string) *lichclient.Client { _ = 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]}) })) 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 +64,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 +128,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 +151,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 +163,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 +174,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 +208,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 { @@ -229,7 +229,7 @@ func fakeState(t *testing.T, projects *[]lichclient.Project, lastScreen string) var args []any _ = 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[1].(string), Kind: "shell"}) _ = json.NewEncoder(w).Encode(nil) default: t.Errorf("unexpected call %s", r.URL.Path) @@ -238,13 +238,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 +272,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 +337,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 +354,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: backend.SessionProfileEvent, 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/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..7c9fad2eb 100644 --- a/native/cmd/lich-native/plugin_gate.go +++ b/native/cmd/lich-native/plugin_gate.go @@ -11,8 +11,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" ) @@ -38,14 +38,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 +69,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 +87,7 @@ type pluginGate struct { started bool mu sync.Mutex - offer []lichclient.PluginStatus + offer []backend.PluginStatus installing bool picked map[string]*widget.Bool @@ -120,7 +120,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 +134,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: backend.PluginIncompatibleEvent, 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 +160,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 +180,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 +264,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..a227cc4ce 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" ) @@ -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..53c47e75a 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) @@ -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..fd73fcd44 100644 --- a/native/cmd/lich-native/projects_test.go +++ b/native/cmd/lich-native/projects_test.go @@ -12,14 +12,14 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "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 +33,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: statusEventName, Data: data}, time.Now()); err != nil { t.Fatal(err) } } @@ -172,14 +172,14 @@ func TestSaveLastScreenWritesTheWebsRoute(t *testing.T) { } } -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 +206,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..ee98c84fa 100644 --- a/native/cmd/lich-native/settings_profiles_look_test.go +++ b/native/cmd/lich-native/settings_profiles_look_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -33,7 +33,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 +48,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: backend.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 +58,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: backend.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 +81,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..a065bbb97 100644 --- a/native/cmd/lich-native/settings_profiles_test.go +++ b/native/cmd/lich-native/settings_profiles_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -39,7 +39,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 +47,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 +58,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 +85,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 +117,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,7 +128,7 @@ 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() @@ -145,7 +145,7 @@ 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 { @@ -154,7 +154,7 @@ func TestCancellingAWaitingLoginEndsItAndClosesTheDialog(t *testing.T) { eventually(t, "the backend was told", func() bool { return slices.Contains(b.log(), `claudeprofile.CancelLogin["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 +164,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,7 +186,7 @@ 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 { @@ -205,17 +205,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 +225,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 +238,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 +248,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 +316,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 +335,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 +359,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,7 +376,7 @@ 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 { @@ -403,7 +403,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,15 +466,15 @@ 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"]]`} @@ -494,22 +494,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 +533,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(backend.CodexProfileLoginEvent) != codexKind || loginEventKind(backend.ClaudeProfileLoginEvent) != claudeKind { t.Error("a login event was routed to the wrong provider") } } @@ -578,7 +578,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 +592,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..47ca763f4 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,7 +188,7 @@ 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 { return m, err 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..6a46ceccf 100644 --- a/native/cmd/lich-native/sidebar_blocks_test.go +++ b/native/cmd/lich-native/sidebar_blocks_test.go @@ -6,18 +6,18 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "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 } @@ -40,7 +40,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{} 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..b74176a50 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} 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..ac71f4541 100644 --- a/native/cmd/lich-native/sidebar_prefs_test.go +++ b/native/cmd/lich-native/sidebar_prefs_test.go @@ -12,7 +12,7 @@ import ( "sync" "testing" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // sidebarBackend is a backend holding settings by key@scope and answering @@ -21,12 +21,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,7 +38,7 @@ 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 { @@ -64,7 +64,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,7 +73,7 @@ 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)}}}} + return backend.PatchNotes{Version: "1.0.0", Groups: []backend.PatchNotesGroup{{Label: "Added", Items: []string{str(0)}}}} case "themes.SetFollowSystem": f.settings["appearance.followSystem@"] = string(args[0]) return nil 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..3b26a7617 100644 --- a/native/cmd/lich-native/status.go +++ b/native/cmd/lich-native/status.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" ) // The states a card draws an indicator for. Every other reported state (the @@ -98,7 +98,7 @@ 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) { +func (s *statusStore) apply(ev backend.Event, now time.Time) (changed bool, err error) { if ev.Name != statusEventName { return false, nil } @@ -259,7 +259,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..d3837bda0 100644 --- a/native/cmd/lich-native/status_test.go +++ b/native/cmd/lich-native/status_test.go @@ -6,18 +6,18 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "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: statusEventName, Data: data} } // step is one report, landing secs seconds after t0. @@ -209,13 +209,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: statusEventName, 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: statusEventName, 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..531acb0ca 100644 --- a/native/cmd/lich-native/stores_events_test.go +++ b/native/cmd/lich-native/stores_events_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -30,7 +30,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 +50,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 +65,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 +147,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 +189,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 +218,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 +244,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 +275,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, backend.ClaudeProfileLoginEvent, backend.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) select { case slugs := <-heard: @@ -312,7 +312,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..7f54f161e 100644 --- a/native/cmd/lich-native/terminal_exit.go +++ b/native/cmd/lich-native/terminal_exit.go @@ -5,8 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" - "github.com/lichdotdev/lich/native/lichclient" "github.com/lichdotdev/lich/native/ui" ) @@ -72,7 +72,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/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..8849d4a05 100644 --- a/native/cmd/lich-native/turnstates_test.go +++ b/native/cmd/lich-native/turnstates_test.go @@ -10,11 +10,11 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/lichclient" + "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 +24,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 +54,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 +70,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 +83,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 +95,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 +134,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 +145,13 @@ 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(), + busy := backend.TurnState{ID: "s1", State: "busy", Tool: "Bash", Since: time.Now().UnixMilli(), Usage: json.RawMessage(opusReport), Cwd: "/elsewhere"} - m := newTestModel(fakeTurnStates(t, []lichclient.TurnState{busy})) + m := newTestModel(fakeTurnStates(t, []backend.TurnState{busy})) toasts := 0 m.notify = func(statusReport) { toasts++ } @@ -210,7 +210,7 @@ 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"}) + m.projects[0].Sessions = append(m.projects[0].Sessions, backend.Session{ID: "s2"}) send(m, statusEventName, statusEvent{ID: "s1", State: "done"}) send(m, statusEventName, statusEvent{ID: "s2", State: "busy"}) 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..ff62cb78b 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) @@ -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/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) } From b35246de916a56e35588277d3cab07ad51f510e7 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:12:22 -0300 Subject: [PATCH 02/13] refactor(rpc): declare the themes service in a typed api contract --- CLAUDE.md | 7 +- CONTRIBUTING.md | 7 +- api/api.go | 62 +++++++ api/api_test.go | 163 +++++++++++++++++++ api/testdata/contract.golden | 94 +++++++++++ api/themes.go | 158 ++++++++++++++++++ docs/ceilings.md | 10 +- internal/cli/theme.go | 15 +- internal/cli/transport.go | 7 + internal/rpc/handle_test.go | 116 +++++++++++++ internal/rpc/rpc.go | 117 +++++++++++-- internal/themes/git.go | 14 +- internal/themes/selection.go | 14 +- internal/themes/syntax.go | 45 +---- internal/themes/themes.go | 51 +----- main.go | 2 +- native/backend/client.go | 7 + native/backend/themes.go | 118 +++----------- native/cmd/lich-native/sidebar_prefs_test.go | 5 +- native/go.mod | 3 + routes.go | 37 +++++ routes_test.go | 36 ++++ 22 files changed, 866 insertions(+), 222 deletions(-) create mode 100644 api/api.go create mode 100644 api/api_test.go create mode 100644 api/testdata/contract.golden create mode 100644 api/themes.go create mode 100644 internal/rpc/handle_test.go create mode 100644 routes.go create mode 100644 routes_test.go diff --git a/CLAUDE.md b/CLAUDE.md index ff61d02e9..d0865ca1a 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/backend`: 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`: 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). A service not moved there yet keeps its shapes + hand-owned in `native/backend`: touch a Go struct's JSON tags and its mirror there moves in the same change. + 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 71725e03f..216e4d9f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,8 +65,11 @@ 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/backend` mirrors them by - hand. There is no codegen, so it moves in the same change. +- **Touched an RPC method's options or result?** Methods declared in `api` are + shared by the backend and the window; run `go test ./api -update` and review + the diff of `api/testdata/contract.golden`. The services not moved there yet + are mirrored by hand in `native/backend`, so the mirror moves in the same + change. 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/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..229e8afce --- /dev/null +++ b/api/api_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "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" + +// TestContractMatchesGolden makes every change to a method's name, options or +// result 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()) + 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) + } + if got != string(want) { + t.Errorf("the contract moved; if that is meant, run `go test ./api -update` and review the diff.\ngot:\n%s", got) + } +} + +// 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) + } + } +} + +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") +} + +// render prints each method as `name(Request) Response`, sorted, followed by +// every named type they reach, each once. +func render(methods map[string]Declared) 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)) + } + 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() + } + 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.Kind() != reflect.Struct { + return fmt.Sprintf("%s = %s\n", t.Name(), r.ref(reflectUnderlying(t))) + } + var b strings.Builder + fmt.Fprintf(&b, "%s {\n", t.Name()) + for field := range t.Fields() { + name, omitempty, wired := jsonName(field) + if !wired { + continue + } + if field.Anonymous { + panic(fmt.Sprintf("api: %s embeds %s; the golden does not render embedded fields", t.Name(), field.Type)) + } + 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/testdata/contract.golden b/api/testdata/contract.golden new file mode 100644 index 000000000..1668d39f4 --- /dev/null +++ b/api/testdata/contract.golden @@ -0,0 +1,94 @@ +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 + +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 +} + +None { +} + +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 +} + +ThemeSource { + url string + version string +} + +Syntax = map[string]SyntaxStyle + +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/ceilings.md b/docs/ceilings.md index d02e643c2..db8387321 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -555,10 +555,12 @@ work when nobody knows it and that the call site never shows. The mechanism and 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/backend`) 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. + `internal/cli/wire_test.go` posts each one as a client of another release. A method declared in `api` (the + themes service, so far) takes one options object from the window as well, and its route reads an empty + argument list as the zero options, so moving a parameterless method there breaks no older caller. The + window's calls not yet declared there (`native/backend`) 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. - **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`, 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..a66725f56 100644 --- a/internal/cli/transport.go +++ b/internal/cli/transport.go @@ -12,6 +12,7 @@ import ( "strconv" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/singleton" ) @@ -57,6 +58,12 @@ func (c *client) call(ctx context.Context, method string, args []any, timeout ti return nil } +// callMethod is call held to the types api declares for m. +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 + return out, c.call(ctx, m.Name, m.Args(req), timeout, &out) +} + // post sends one RPC and reads the whole reply. The body is read here rather // than returned open because a refused call is sent a second time, and the // first response has to be finished with before the second one starts. diff --git a/internal/rpc/handle_test.go b/internal/rpc/handle_test.go new file mode 100644 index 000000000..01ee8b58c --- /dev/null +++ b/internal/rpc/handle_test.go @@ -0,0 +1,116 @@ +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()) + } +} + +// A service moves to api one method at a time: the handled method answers, +// the ones still registered by reflection keep answering beside it. +func TestHandleAnswersBeforeARegisteredServiceOfTheSameName(t *testing.T) { + h := New() + h.Register("fake", fakeService{}) + Handle(h, api.Method[api.None, string]{Name: "fake.Fail"}, func(context.Context, api.None) (string, error) { return "typed", nil }) + if rec := call(t, h, "fake.Fail", `[]`); strings.TrimSpace(rec.Body.String()) != `"typed"` { + t.Errorf("handled method: got %d %q", rec.Code, rec.Body.String()) + } + if rec := call(t, h, "fake.Add", `[2, 3]`); strings.TrimSpace(rec.Body.String()) != "5" { + t.Errorf("registered method beside it: got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestHandleHonorsDeny(t *testing.T) { + h := newTypedHandler() + h.Deny("typed.Echo") + if rec := call(t, h, "typed.Echo", `[{"a":"x"}]`); rec.Code != http.StatusNotFound { + t.Fatalf("denied handled method: want 404, got %d", rec.Code) + } +} + +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..ccd348939 100644 --- a/internal/rpc/rpc.go +++ b/internal/rpc/rpc.go @@ -6,22 +6,27 @@ // → 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. +// A method declared in api is exposed with Handle, which the compiler holds to +// the method's options and result types: the array carries that one options +// object, or nothing. The rest is reflection over registered services — the +// net/rpc pattern — matching the array to the method's parameters by +// position; a method whose first parameter is a context.Context gets the +// request's. Both hand over a context that 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 @@ -34,17 +39,72 @@ var ( ) type Handler struct { + routes map[string]route services map[string]reflect.Value denied map[string]bool } +// 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{ + routes: make(map[string]route), services: make(map[string]reflect.Value), denied: make(map[string]bool), } } +// Handle exposes fn as m. It answers before a Register of the same service, +// so a service moves to api one method at a time. +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 + } +} + +// 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)) +} + // Register exposes every exported method of svc under name.. func (h *Handler) Register(name string, svc any) { h.services[name] = reflect.ValueOf(svc) @@ -64,6 +124,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } name := strings.TrimPrefix(r.URL.Path, "/rpc/") + if route, ok := h.routes[name]; ok && !h.denied[name] { + h.serveRoute(w, r, name, route) + return + } method, err := h.lookup(name) if err != nil { slog.Warn("rpc: unknown method", "method", name, "err", err) @@ -71,10 +135,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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") + body, ok := readBody(w, r, name) + if !ok { return } args, err := decodeArgs(r.Context(), method.Type(), body) @@ -87,6 +149,31 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeResult(w, name, method.Call(args)) } +func (h *Handler) serveRoute(w http.ResponseWriter, r *http.Request, name string, route route) { + body, ok := readBody(w, r, name) + if !ok { + return + } + 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 + } + writeReply(w, name, result, err) +} + +func readBody(w http.ResponseWriter, r *http.Request, name string) ([]byte, bool) { + 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 nil, false + } + return body, true +} + func (h *Handler) lookup(name string) (reflect.Value, error) { if h.denied[name] { return reflect.Value{}, fmt.Errorf("method %q not available", name) @@ -143,8 +230,7 @@ func writeResult(w http.ResponseWriter, method string, results []reflect.Value) 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()) + writeReply(w, method, nil, err) return } continue @@ -153,6 +239,15 @@ func writeResult(w http.ResponseWriter, method string, results []reflect.Value) payload = result.Interface() } } + writeReply(w, method, payload, nil) +} + +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 { // Headers are gone — the response cannot change anymore, but the 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..64016ab15 100644 --- a/internal/themes/selection.go +++ b/internal/themes/selection.go @@ -4,6 +4,8 @@ import ( "fmt" "slices" "strconv" + + "github.com/lichdotdev/lich/api" ) // The settings the windows read the selection from, global ones. A theme @@ -19,12 +21,7 @@ const ( // 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 +45,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 { 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/main.go b/main.go index 75ba0acee..304d87d82 100644 --- a/main.go +++ b/main.go @@ -434,7 +434,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // 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)) + handleThemes(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) diff --git a/native/backend/client.go b/native/backend/client.go index ee4620419..962eaf261 100644 --- a/native/backend/client.go +++ b/native/backend/client.go @@ -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 @@ -154,6 +155,12 @@ func (c *Client) Call(ctx context.Context, method string, out any, args ...any) return nil } +// callMethod is Call held to the types api declares for m. +func callMethod[Req, Resp any](ctx context.Context, c *Client, m api.Method[Req, Resp], req Req) (Resp, error) { + var out Resp + return out, c.Call(ctx, m.Name, &out, m.Args(req)...) +} + // RemoteError is a call the backend refused. Message is the backend's own // sentence, the one a person reads; Error adds the method and status for logs. type RemoteError struct { diff --git a/native/backend/themes.go b/native/backend/themes.go index f8e6d67a3..a65d08d7d 100644 --- a/native/backend/themes.go +++ b/native/backend/themes.go @@ -2,145 +2,79 @@ package backend import ( "context" - "encoding/json" + + "github.com/lichdotdev/lich/api" ) -// 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"` -} +// 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 +) // 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) + 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 { - return c.Call(ctx, "themes.Select", nil, struct { - ID string `json:"id"` - }{id}) + _, 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 { - 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"` + _, 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) { - var out []Theme - return out, c.Call(ctx, "themes.List", &out) + 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) { - var out []BrokenTheme - return out, c.Call(ctx, "themes.ListBroken", &out) + 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) { - var out ThemeImportResult - return out, c.Call(ctx, "themes.Import", &out, path, overwrite) + 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) { - var out ThemeGitResult - return out, c.Call(ctx, "themes.InstallFromGit", &out, url, overwrite) + 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) { - var out ThemeGitResult - return out, c.Call(ctx, "themes.UpdateFromGit", &out, id) + 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 { - return c.Call(ctx, "themes.Remove", nil, id) + _, 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 { - return c.Call(ctx, "themes.SaveTemplate", nil, path) + _, err := callMethod(ctx, c, api.ThemesSaveTemplate, api.SaveThemeTemplateOptions{Path: path}) + return err } // PickFile opens the backend's file picker; "" is a cancelled one. diff --git a/native/cmd/lich-native/sidebar_prefs_test.go b/native/cmd/lich-native/sidebar_prefs_test.go index ac71f4541..1c47c9f39 100644 --- a/native/cmd/lich-native/sidebar_prefs_test.go +++ b/native/cmd/lich-native/sidebar_prefs_test.go @@ -12,6 +12,7 @@ import ( "sync" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -75,7 +76,9 @@ func (f *sidebarBackend) answer(method string, args []json.RawMessage) any { case "patchnotes.Current": return backend.PatchNotes{Version: "1.0.0", Groups: []backend.PatchNotesGroup{{Label: "Added", Items: []string{str(0)}}}} 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/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/routes.go b/routes.go new file mode 100644 index 000000000..1ea100a9a --- /dev/null +++ b/routes.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/themes" +) + +// handleThemes exposes the themes service as api declares it. +func handleThemes(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) + }) +} diff --git a/routes_test.go b/routes_test.go new file mode 100644 index 000000000..fa4fbe7c6 --- /dev/null +++ b/routes_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/rpc" + "github.com/lichdotdev/lich/internal/themes" +) + +// TestHandleThemesCoversEveryThemesMethod fails on a themes method api +// declares and no route 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 TestHandleThemesCoversEveryThemesMethod(t *testing.T) { + d := rpc.New() + handleThemes(d, themes.NewInDir(t.TempDir())) + seen := 0 + for name := range api.Methods() { + if !strings.HasPrefix(name, "themes.") { + 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.Fatal("api declares no themes method: the scan no longer sees them") + } +} From 5e76d86872b865ac75ca30532ee68bce33bd024b Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:17:30 -0300 Subject: [PATCH 03/13] refactor(rpc): declare the spawn service in the api contract --- CLAUDE.md | 10 +- api/spawn.go | 285 +++++++++++++++++++++++++++++++++ api/testdata/contract.golden | 178 ++++++++++++++++++++ docs/ceilings.md | 5 +- internal/cli/ask.go | 14 +- internal/cli/cli.go | 42 ++--- internal/cli/control.go | 15 +- internal/cli/folders.go | 54 +++---- internal/cli/mcp.go | 28 ++-- internal/cli/rpcshape_test.go | 8 +- internal/cli/wire_test.go | 7 +- internal/routes/routes.go | 66 ++++++++ internal/routes/routes_test.go | 48 ++++++ internal/spawn/ask.go | 17 +- internal/spawn/close.go | 33 +--- internal/spawn/control.go | 25 +-- internal/spawn/folders.go | 71 ++------ internal/spawn/rename.go | 19 +-- internal/spawn/spawn.go | 90 +---------- internal/spawn/worktrees.go | 22 +-- main.go | 8 +- routes.go | 37 ----- routes_test.go | 36 ----- 23 files changed, 698 insertions(+), 420 deletions(-) create mode 100644 api/spawn.go create mode 100644 internal/routes/routes.go create mode 100644 internal/routes/routes_test.go delete mode 100644 routes.go delete mode 100644 routes_test.go diff --git a/CLAUDE.md b/CLAUDE.md index d0865ca1a..0fc5ccd14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,11 +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`. -- An RPC method is declared once in `api` and exposed with `rpc.Handle`: 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). A service not moved there yet keeps its shapes - hand-owned in `native/backend`: 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). A service not moved there yet keeps its + shapes hand-owned in `native/backend`: touch a Go struct's JSON tags and its mirror there moves in the same + change. 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/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/testdata/contract.golden b/api/testdata/contract.golden index 1668d39f4..cf690d1f7 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -1,3 +1,14 @@ +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 themes.Import(ImportThemeOptions) ThemeImportResult themes.InstallFromGit(InstallThemesOptions) ThemeGitResult themes.List(None) []Theme @@ -9,6 +20,173 @@ themes.Selection(None) ThemeSelection themes.SetFollowSystem(FollowSystemOptions) None themes.UpdateFromGit(UpdateThemeOptions) ThemeGitResult +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 +} + ImportThemeOptions { path string overwrite bool diff --git a/docs/ceilings.md b/docs/ceilings.md index db8387321..0a653c0ae 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -556,8 +556,9 @@ work when nobody knows it and that the call site never shows. The mechanism and `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. A method declared in `api` (the - themes service, so far) takes one options object from the window as well, and its route reads an empty - argument list as the zero options, so moving a parameterless method there breaks no older caller. The + themes and spawn services, so far) takes one options object from every client, the window included, and its + route reads an empty argument list as the zero options, so moving a parameterless method there breaks no older + caller. The window's calls not yet declared there (`native/backend`) 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. 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..57bd7f918 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" @@ -642,24 +643,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) @@ -905,11 +906,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 +956,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 +988,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/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/mcp.go b/internal/cli/mcp.go index 434a6ad96..42acf6558 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" @@ -634,13 +635,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 +672,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 +700,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 +722,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)) diff --git a/internal/cli/rpcshape_test.go b/internal/cli/rpcshape_test.go index 2ba4aaa4f..733be8012 100644 --- a/internal/cli/rpcshape_test.go +++ b/internal/cli/rpcshape_test.go @@ -39,12 +39,14 @@ var cliServices = map[string]reflect.Type{ // (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. +// it takes something, that something is the object. A call made through a +// method api declares is not posted by name and so not seen here; api's +// TestEveryRequestIsOneObject holds it to the same rule. 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) + if !methods["relay.Peers"] { + t.Fatalf("found %v, want relay.Peers among them: the scan no longer sees the CLI's calls", methods) } for name := range methods { service, method, _ := strings.Cut(name, ".") diff --git a/internal/cli/wire_test.go b/internal/cli/wire_test.go index dfdbb8fed..633956206 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" @@ -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) { diff --git a/internal/routes/routes.go b/internal/routes/routes.go new file mode 100644 index 000000000..e656b5329 --- /dev/null +++ b/internal/routes/routes.go @@ -0,0 +1,66 @@ +// 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. +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..0e960eeec --- /dev/null +++ b/internal/routes/routes_test.go @@ -0,0 +1,48 @@ +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" +) + +func TestHandleThemesCoversEveryThemesMethod(t *testing.T) { + d := rpc.New() + Themes(d, themes.NewInDir(t.TempDir())) + assertHandled(t, d, "themes.") +} + +// 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/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..6193e56a6 100644 --- a/internal/spawn/close.go +++ b/internal/spawn/close.go @@ -5,6 +5,7 @@ import ( "log/slog" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" ) @@ -20,37 +21,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. 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..8a8522cb7 100644 --- a/internal/spawn/folders.go +++ b/internal/spawn/folders.go @@ -6,6 +6,7 @@ import ( "slices" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/relay" "github.com/lichdotdev/lich/internal/store" ) @@ -41,40 +42,13 @@ type ColoredEvent struct { // 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 +86,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 @@ -157,14 +125,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 @@ -201,23 +162,9 @@ func (s *Service) RenameFolder(opts RenameFolderOptions) (Refiled, error) { 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 diff --git a/internal/spawn/rename.go b/internal/spawn/rename.go index d7cf85d44..21badc715 100644 --- a/internal/spawn/rename.go +++ b/internal/spawn/rename.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/store" ) @@ -25,23 +26,9 @@ type RenamedEvent struct { Label string `json:"label"` } -// 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"` -} +type Renamed = api.RenamedSession -// 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..9c48b94e6 100644 --- a/internal/spawn/spawn.go +++ b/internal/spawn/spawn.go @@ -29,6 +29,7 @@ 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" @@ -137,37 +138,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 +157,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 +168,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 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/main.go b/main.go index 304d87d82..21fc02c34 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,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" @@ -433,8 +434,8 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.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) - handleThemes(dispatcher, 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) @@ -486,8 +487,6 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // 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, @@ -555,7 +554,6 @@ func denyInternal(d *rpc.Handler) { "relay.SetPlugins", "relay.SetWorkerFinished", "relay.SetPromptLanguage", - "spawn.CloseFinishedWorker", "project.SetAccounts", "project.SetProjects", "quota.SetSessions", diff --git a/routes.go b/routes.go deleted file mode 100644 index 1ea100a9a..000000000 --- a/routes.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "context" - - "github.com/lichdotdev/lich/api" - "github.com/lichdotdev/lich/internal/rpc" - "github.com/lichdotdev/lich/internal/themes" -) - -// handleThemes exposes the themes service as api declares it. -func handleThemes(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) - }) -} diff --git a/routes_test.go b/routes_test.go deleted file mode 100644 index fa4fbe7c6..000000000 --- a/routes_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/lichdotdev/lich/api" - "github.com/lichdotdev/lich/internal/rpc" - "github.com/lichdotdev/lich/internal/themes" -) - -// TestHandleThemesCoversEveryThemesMethod fails on a themes method api -// declares and no route 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 TestHandleThemesCoversEveryThemesMethod(t *testing.T) { - d := rpc.New() - handleThemes(d, themes.NewInDir(t.TempDir())) - seen := 0 - for name := range api.Methods() { - if !strings.HasPrefix(name, "themes.") { - 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.Fatal("api declares no themes method: the scan no longer sees them") - } -} From baeda27baf6e4c31ff279afd15844e9dfc55ddec Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:25:21 -0300 Subject: [PATCH 04/13] refactor(rpc): declare the relay, system and tray services and store.CostTotals in the api contract --- api/relay.go | 171 +++++++++++++++++ api/store.go | 67 +++++++ api/system.go | 72 +++++++ api/testdata/contract.golden | 200 +++++++++++++++++++- docs/ceilings.md | 19 +- internal/cli/cli.go | 72 ++++--- internal/cli/cost.go | 6 +- internal/cli/hangup_test.go | 4 +- internal/cli/mcp.go | 24 ++- internal/cli/quit.go | 3 +- internal/cli/rpcshape_test.go | 115 ----------- internal/cli/transport.go | 42 ++-- internal/cli/wire_test.go | 8 +- internal/relay/answer.go | 16 +- internal/relay/focus.go | 17 +- internal/relay/inbox.go | 22 +-- internal/relay/insert.go | 23 +-- internal/relay/relay.go | 64 +------ internal/relay/roster.go | 6 +- internal/routes/relay.go | 34 ++++ internal/routes/routes_test.go | 25 +++ internal/routes/store.go | 14 ++ internal/routes/system.go | 51 +++++ internal/singleton/singleton.go | 13 +- internal/store/cost.go | 65 +------ internal/system/native.go | 9 +- internal/system/system.go | 9 +- internal/system/window.go | 10 +- main.go | 7 +- native/backend/project.go | 46 +++-- native/backend/pulls.go | 14 +- native/backend/window.go | 27 +-- native/cmd/lich-native/gates_click_test.go | 4 +- native/cmd/lich-native/paste_unfold_test.go | 8 +- native/cmd/lich-native/settings_sandbox.go | 5 +- native/cmd/lich-native/worktree_dialog.go | 4 +- 36 files changed, 827 insertions(+), 469 deletions(-) create mode 100644 api/relay.go create mode 100644 api/store.go create mode 100644 api/system.go delete mode 100644 internal/cli/rpcshape_test.go create mode 100644 internal/routes/relay.go create mode 100644 internal/routes/store.go create mode 100644 internal/routes/system.go 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/store.go b/api/store.go new file mode 100644 index 000000000..a38050bb6 --- /dev/null +++ b/api/store.go @@ -0,0 +1,67 @@ +package api + +var StoreCostTotals = define[CostTotalsOptions, CostReport]("store.CostTotals") + +// 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"` +} 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/testdata/contract.golden b/api/testdata/contract.golden index cf690d1f7..052b05556 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -1,3 +1,16 @@ +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 @@ -9,6 +22,20 @@ spawn.OpenSubagent(OpenSubagentOptions) OpenedSession spawn.Rename(RenameSessionOptions) RenamedSession spawn.RenameFolder(RenameFolderOptions) RenamedFolder spawn.Worktrees(WorktreesOptions) []WorktreeCheckout +store.CostTotals(CostTotalsOptions) CostReport +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 themes.Import(ImportThemeOptions) ThemeImportResult themes.InstallFromGit(InstallThemesOptions) ThemeGitResult themes.List(None) []Theme @@ -19,6 +46,111 @@ themes.Select(SelectThemeOptions) None themes.Selection(None) ThemeSelection themes.SetFollowSystem(FollowSystemOptions) None themes.UpdateFromGit(UpdateThemeOptions) ThemeGitResult +tray.SetLabels(TrayLabels) None + +AnnounceMergeOptions { + checkout string + number int + title string + branch string + base string +} + +None { +} + +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 @@ -187,6 +319,57 @@ WorktreeCheckout { sessions []string } +CostTotalsOptions { + project string + provider string + since int64 +} + +CostReport { + projects []CostTotal + sessions int + unpriced int + priced int + reported int + source string + costUsd float64 + readout bool +} + +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 { +} + ImportThemeOptions { path string overwrite bool @@ -210,9 +393,6 @@ ThemeGitResult { upToDate bool } -None { -} - Theme { formatVersion,omitempty int id string @@ -256,6 +436,20 @@ UpdateThemeOptions { id string } +TrayLabels { + show string + running string + quit string +} + +CostTotal { + project string + sessions int + unpriced int + source string + costUsd float64 +} + ThemeSource { url string version string diff --git a/docs/ceilings.md b/docs/ceilings.md index 0a653c0ae..6c25cc2d5 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -551,17 +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. A method declared in `api` (the - themes and spawn services, so far) takes one options object from every client, the window included, and its - route reads an empty argument list as the zero options, so moving a parameterless method there breaks no older - caller. The - window's calls not yet declared there (`native/backend`) 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 not yet declared + in `api` (`native/backend`) 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. - **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`, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 57bd7f918..adf9fa608 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -73,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 } @@ -301,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) @@ -334,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 { @@ -375,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 { @@ -418,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 { @@ -455,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 { @@ -527,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 { @@ -570,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 { @@ -584,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 { @@ -605,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.") @@ -725,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 } @@ -750,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 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/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 42acf6558..f4b0c3e4a 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -481,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 { @@ -522,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 @@ -562,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 @@ -749,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 @@ -766,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) @@ -776,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 733be8012..000000000 --- a/internal/cli/rpcshape_test.go +++ /dev/null @@ -1,115 +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. A call made through a -// method api declares is not posted by name and so not seen here; api's -// TestEveryRequestIsOneObject holds it to the same rule. -func TestEveryCallTheCLIPostsTakesOneOptionsObject(t *testing.T) { - t.Parallel() - methods := postedMethods(t) - if !methods["relay.Peers"] { - t.Fatalf("found %v, want relay.Peers 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/transport.go b/internal/cli/transport.go index a66725f56..0a6245e9d 100644 --- a/internal/cli/transport.go +++ b/internal/cli/transport.go @@ -17,22 +17,25 @@ import ( "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, @@ -40,28 +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 -} - -// callMethod is call held to the types api declares for m. -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 - return out, c.call(ctx, m.Name, m.Args(req), timeout, &out) + 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 633956206..1c66252aa 100644 --- a/internal/cli/wire_test.go +++ b/internal/cli/wire_test.go @@ -94,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) @@ -884,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}]`) @@ -920,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) @@ -935,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/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..07785bef1 100644 --- a/internal/relay/focus.go +++ b/internal/relay/focus.go @@ -1,5 +1,7 @@ package relay +import "github.com/lichdotdev/lich/api" + // 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. @@ -10,13 +12,7 @@ type FocusEvent struct { ID string `json:"id"` } -// 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 +21,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. diff --git a/internal/relay/inbox.go b/internal/relay/inbox.go index 716c8e6ba..6d13a113e 100644 --- a/internal/relay/inbox.go +++ b/internal/relay/inbox.go @@ -7,6 +7,8 @@ import ( "sort" "sync" "time" + + "github.com/lichdotdev/lich/api" ) // The inbox: where an errand's outcome waits when nobody was holding the line @@ -29,12 +31,7 @@ type InboxEvent struct { Count int `json:"count"` } -// 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 +59,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 +74,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 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/relay.go b/internal/relay/relay.go index 3f914b720..525513620 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" ) @@ -241,50 +242,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 +520,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 +538,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/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/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_test.go b/internal/routes/routes_test.go index 0e960eeec..d883ed3ea 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -18,6 +18,31 @@ func TestHandleThemesCoversEveryThemesMethod(t *testing.T) { 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.") +} + // 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 diff --git a/internal/routes/store.go b/internal/routes/store.go new file mode 100644 index 000000000..c1c634041 --- /dev/null +++ b/internal/routes/store.go @@ -0,0 +1,14 @@ +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) { + rpc.Handle(d, api.StoreCostTotals, func(_ context.Context, o api.CostTotalsOptions) (api.CostReport, error) { return s.CostTotals(o) }) +} 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/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/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/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/main.go b/main.go index 21fc02c34..7e63dfdf9 100644 --- a/main.go +++ b/main.go @@ -312,6 +312,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub dispatcher.Register("appupdate", appupdate.New(version, coord.Install, hub.Emit)) dispatcher.Register("patchnotes", patchnotes.New(version, changelog, changelogTranslations)) dispatcher.Register("store", db) + 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. @@ -326,7 +327,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, @@ -427,7 +428,7 @@ 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) @@ -439,7 +440,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // 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) + routes.Tray(dispatcher, trayIcon) denyInternal(dispatcher) term.Mount("/rpc/", dispatcher) term.Mount("/drop", http.HandlerFunc(drops.Upload)) diff --git a/native/backend/project.go b/native/backend/project.go index 5935ac0d1..2595c57f6 100644 --- a/native/backend/project.go +++ b/native/backend/project.go @@ -1,6 +1,10 @@ package backend -import "context" +import ( + "context" + + "github.com/lichdotdev/lich/api" +) // BaseStatus mirrors project.BaseStatus: where a checkout stands against the // branch it merges into. @@ -33,50 +37,58 @@ func (c *Client) PullRequest(ctx context.Context, path string) (*PullRequest, er // 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) + _, 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) { - var command string - return command, c.Call(ctx, "system.OpenFolderInEditor", &command, dir) + 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 { - return c.Call(ctx, "system.OpenFolder", nil, dir) + _, err := callMethod(ctx, c, api.SystemOpenFolder, api.OpenFolderOptions{Dir: dir}) + return err } -// 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 -} +type Diagnostics = api.Diagnostics func (c *Client) Diagnostics(ctx context.Context) (Diagnostics, error) { - var d Diagnostics - return d, c.Call(ctx, "system.Diagnostics", &d) + 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 { - return c.Call(ctx, "system.RevealLog", nil) + _, 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) { - var unclean bool - return unclean, c.Call(ctx, "system.TakeUncleanExit", &unclean) + 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 { - return c.Call(ctx, "system.OpenFile", nil, dir, rel) + _, 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{}) } diff --git a/native/backend/pulls.go b/native/backend/pulls.go index 39e0cb23a..3fececabc 100644 --- a/native/backend/pulls.go +++ b/native/backend/pulls.go @@ -1,6 +1,10 @@ package backend -import "context" +import ( + "context" + + "github.com/lichdotdev/lich/api" +) // PRChangedEvent is project.PRChangedEvent: a pull request write through lich // landed on GitHub. @@ -182,13 +186,17 @@ func (c *Client) PurgeWorktreeSessions(ctx context.Context, projectID, path stri // 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) + _, 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 { - return c.Call(ctx, "relay.Submit", nil, id, message) + _, err := callMethod(ctx, c, api.RelaySubmit, api.SubmitOptions{SessionID: id, Message: message}) + return err } // ReviewComment mirrors project.ReviewComment: one line comment of a review. diff --git a/native/backend/window.go b/native/backend/window.go index 67b44aa7e..0f4c4731c 100644 --- a/native/backend/window.go +++ b/native/backend/window.go @@ -1,6 +1,10 @@ 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) { @@ -11,33 +15,30 @@ func (c *Client) LiveCount(ctx context.Context) (int, error) { // 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, diff --git a/native/cmd/lich-native/gates_click_test.go b/native/cmd/lich-native/gates_click_test.go index 96e06ca61..03e31b187 100644 --- a/native/cmd/lich-native/gates_click_test.go +++ b/native/cmd/lich-native/gates_click_test.go @@ -230,7 +230,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() { @@ -306,7 +306,7 @@ func TestUpdateToastViewReleaseOpensItsPage(t *testing.T) { u, b := newGateWindow(t) 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) } } diff --git a/native/cmd/lich-native/paste_unfold_test.go b/native/cmd/lich-native/paste_unfold_test.go index 76e29b259..4a6ab781b 100644 --- a/native/cmd/lich-native/paste_unfold_test.go +++ b/native/cmd/lich-native/paste_unfold_test.go @@ -10,6 +10,8 @@ import ( "time" "github.com/lichdotdev/lich/native/gioterm" + + "github.com/lichdotdev/lich/api" ) const unfoldRows = 40 @@ -126,7 +128,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 +136,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/settings_sandbox.go b/native/cmd/lich-native/settings_sandbox.go index 47ca763f4..933d275ef 100644 --- a/native/cmd/lich-native/settings_sandbox.go +++ b/native/cmd/lich-native/settings_sandbox.go @@ -190,10 +190,11 @@ func (s *settingsScreen) loadSandbox() { 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/worktree_dialog.go b/native/cmd/lich-native/worktree_dialog.go index ff62cb78b..829a6e5b7 100644 --- a/native/cmd/lich-native/worktree_dialog.go +++ b/native/cmd/lich-native/worktree_dialog.go @@ -140,8 +140,8 @@ func preferredBase(b backend.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 } From 2f6670017b38df0a9586ab3634d5b5766439c434 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:30:04 -0300 Subject: [PATCH 05/13] refactor(rpc): declare the drop, patchnotes, appupdate, agentplugin and providers services in the api contract --- api/agentplugin.go | 34 ++++++ api/appupdate.go | 18 ++++ api/drop.go | 20 ++++ api/patchnotes.go | 30 ++++++ api/providers.go | 61 +++++++++++ api/testdata/contract.golden | 101 +++++++++++++++++- internal/agentplugin/agentplugin.go | 22 +--- internal/appupdate/appupdate.go | 13 +-- internal/drop/drop.go | 10 +- internal/patchnotes/patchnotes.go | 24 +---- internal/providers/providers.go | 20 +--- internal/providers/verify.go | 13 +-- internal/providers/version.go | 8 +- internal/routes/app.go | 56 ++++++++++ internal/routes/routes_test.go | 14 +++ main.go | 10 +- native/backend/appupdate.go | 5 +- native/backend/footer.go | 16 ++- native/backend/footer_test.go | 2 +- native/backend/store.go | 24 ++--- native/backend/store_test.go | 6 +- native/backend/update.go | 68 ++++-------- native/backend/update_test.go | 4 +- native/backend/vcs.go | 41 +++---- native/cmd/lich-native/footer_readout_test.go | 5 +- native/cmd/lich-native/gates_click_test.go | 8 +- native/cmd/lich-native/sidebar_prefs_test.go | 4 +- 27 files changed, 425 insertions(+), 212 deletions(-) create mode 100644 api/agentplugin.go create mode 100644 api/appupdate.go create mode 100644 api/drop.go create mode 100644 api/patchnotes.go create mode 100644 api/providers.go create mode 100644 internal/routes/app.go 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/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/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/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/testdata/contract.golden b/api/testdata/contract.golden index 052b05556..79e46a526 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -1,3 +1,15 @@ +agentplugin.Install(PluginProviderOptions) None +agentplugin.Status(None) []PluginStatus +agentplugin.Update(PluginProviderOptions) None +appupdate.Apply(None) None +appupdate.Status(None) AppUpdateStatus +drop.Attach(AttachDropOptions) DropAttachment +patchnotes.Current(PatchNotesOptions) PatchNotes +providers.Detect(None) []DetectedProvider +providers.OnPath(ProviderIDOptions) []ProviderBinary +providers.RefreshPath(None) None +providers.Verify(BinaryOptions) BinaryCheck +providers.Version(BinaryOptions) string relay.AnnounceMerge(AnnounceMergeOptions) None relay.Collect(CollectOptions) CollectedResults relay.CollectNow(CollectNowOptions) CollectedResults @@ -48,6 +60,82 @@ themes.SetFollowSystem(FollowSystemOptions) None themes.UpdateFromGit(UpdateThemeOptions) ThemeGitResult tray.SetLabels(TrayLabels) 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 +} + +AttachDropOptions { + sessionId string + root string + confined bool +} + +DropAttachment { + path string + notice string +} + +PatchNotesOptions { + locale string +} + +PatchNotes { + version string + highlights []PatchNotesHighlight + groups []PatchNotesGroup +} + +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 +} + AnnounceMergeOptions { checkout string number int @@ -56,9 +144,6 @@ AnnounceMergeOptions { base string } -None { -} - CollectOptions { from string waitSeconds int @@ -442,6 +527,16 @@ TrayLabels { quit string } +PatchNotesHighlight { + kind string + text string +} + +PatchNotesGroup { + label string + items []string +} + CostTotal { project string sessions int 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/appupdate/appupdate.go b/internal/appupdate/appupdate.go index a39504f70..dfaae0c65 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" ) @@ -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/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/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/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/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/routes_test.go b/internal/routes/routes_test.go index d883ed3ea..3d5a2eb6e 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -43,6 +43,20 @@ func TestHandleStoreCoversEveryStoreMethod(t *testing.T) { 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) }, + } { + d := rpc.New() + route(d) + assertHandled(t, d, prefix) + } +} + // 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 diff --git a/main.go b/main.go index 7e63dfdf9..d3f2cb862 100644 --- a/main.go +++ b/main.go @@ -264,10 +264,10 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // (drop.Attach says why that matters). drops.SetPicker(proj.PickFile) dispatcher.Register("terminal", term) - dispatcher.Register("drop", drops) + routes.Drop(dispatcher, drops) dispatcher.Register("project", 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 @@ -309,8 +309,8 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub ) dispatcher.Register(provider.ID+"profile", profiles) } - dispatcher.Register("appupdate", appupdate.New(version, coord.Install, hub.Emit)) - dispatcher.Register("patchnotes", patchnotes.New(version, changelog, changelogTranslations)) + routes.AppUpdate(dispatcher, appupdate.New(version, coord.Install, hub.Emit)) + routes.PatchNotes(dispatcher, patchnotes.New(version, changelog, changelogTranslations)) dispatcher.Register("store", db) routes.Store(dispatcher, db) // Read before run writes this run's runtime file over the previous @@ -365,7 +365,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. diff --git a/native/backend/appupdate.go b/native/backend/appupdate.go index 38781820c..0c194c0d2 100644 --- a/native/backend/appupdate.go +++ b/native/backend/appupdate.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "net/http" + + "github.com/lichdotdev/lich/api" ) // AppUpdateProgressEvent is appupdate.ProgressEventName: one step of @@ -28,7 +30,8 @@ type AppUpdateProgress struct { // 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/backend/footer.go b/native/backend/footer.go index 1a03d661e..e945fdc2b 100644 --- a/native/backend/footer.go +++ b/native/backend/footer.go @@ -1,6 +1,10 @@ package backend -import "context" +import ( + "context" + + "github.com/lichdotdev/lich/api" +) // QuotaWindow mirrors quota.Window (internal/quota/quota.go). type QuotaWindow struct { @@ -25,12 +29,7 @@ type QuotaPlan struct { 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"` -} +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 @@ -50,8 +49,7 @@ func (c *Client) HandsOn(ctx context.Context, id string) (int64, error) { // 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) + return callMethod(ctx, c, api.DropAttach, api.AttachDropOptions{SessionID: sessionID, Root: root, Confined: confined}) } // Write types data into session id's PTY. diff --git a/native/backend/footer_test.go b/native/backend/footer_test.go index e08a0b0c7..cf4e98749 100644 --- a/native/backend/footer_test.go +++ b/native/backend/footer_test.go @@ -34,7 +34,7 @@ func TestFooterCallsSendPositionalArgs(t *testing.T) { _, 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 }}, diff --git a/native/backend/store.go b/native/backend/store.go index 3b15296ef..69637686c 100644 --- a/native/backend/store.go +++ b/native/backend/store.go @@ -1,19 +1,12 @@ package backend -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"` -} +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. @@ -56,6 +49,5 @@ func (c *Client) AddSessionInFolder(ctx context.Context, projectID, sessionID, l // 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/backend/store_test.go b/native/backend/store_test.go index 2e0d5f2df..aee463bc1 100644 --- a/native/backend/store_test.go +++ b/native/backend/store_test.go @@ -32,11 +32,11 @@ func TestStoreCallsSendPositionalArgs(t *testing.T) { _, 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 }}, @@ -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/update.go b/native/backend/update.go index 89a779085..468f6f9b7 100644 --- a/native/backend/update.go +++ b/native/backend/update.go @@ -1,29 +1,14 @@ package backend -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"` -} +import ( + "context" -// 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"` -} + "github.com/lichdotdev/lich/api" +) + +type AppUpdateStatus = api.AppUpdateStatus + +type PluginStatus = api.PluginStatus // PluginIncompatibleEvent is raised when a session's hooks report from a // lich plugin release outside the range this lich speaks. @@ -50,56 +35,39 @@ type PluginProfileFailure struct { 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"` -} +type PatchNotesGroup = api.PatchNotesGroup -// PatchNotesHighlight mirrors patchnotes.Highlight: Kind is "important", -// "warning" or "note". -type PatchNotesHighlight struct { - Kind string `json:"kind"` - Text string `json:"text"` -} +type PatchNotesHighlight = api.PatchNotesHighlight -// 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"` -} +type PatchNotes = api.PatchNotes // 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) + 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) { - var n PatchNotes - return n, c.Call(ctx, "patchnotes.Current", &n, locale) + 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) { - var statuses []PluginStatus - return statuses, c.Call(ctx, "agentplugin.Status", &statuses) + 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 { - return c.Call(ctx, "agentplugin.Install", nil, provider) + _, 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 { - return c.Call(ctx, "agentplugin.Update", nil, provider) + _, err := callMethod(ctx, c, api.AgentPluginUpdate, api.PluginProviderOptions{Provider: provider}) + return err } diff --git a/native/backend/update_test.go b/native/backend/update_test.go index 4f6064a3e..3c174e7ba 100644 --- a/native/backend/update_test.go +++ b/native/backend/update_test.go @@ -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 index 9d0458a63..a8d0daa7a 100644 --- a/native/backend/vcs.go +++ b/native/backend/vcs.go @@ -1,23 +1,17 @@ package backend -import "context" +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" + "github.com/lichdotdev/lich/api" ) -// 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"` -} +const ( + CheckOK = api.CheckOK + CheckEmpty = api.CheckEmpty +) -// Failed reports a check to show as a problem. -func (b BinaryCheck) Failed() bool { return b.Status != CheckOK && b.Status != CheckEmpty } +type BinaryCheck = api.BinaryCheck // CommitIdentity mirrors project.CommitIdentity: who the next commit in a // checkout would be authored as. Every field empty means none is configured. @@ -29,35 +23,28 @@ type CommitIdentity struct { // 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) + return callMethod(ctx, c, api.ProvidersVerify, api.BinaryOptions{Bin: 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"` -} +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) { - var v string - return v, c.Call(ctx, "providers.Version", &v, bin) + 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) { - var found []ProviderBinary - return found, c.Call(ctx, "providers.OnPath", &found, id) + 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 { - return c.Call(ctx, "providers.RefreshPath", nil) + _, err := callMethod(ctx, c, api.ProvidersRefreshPath, api.None{}) + return err } // GitHubAccounts lists gh's signed-in accounts as "/"; empty diff --git a/native/cmd/lich-native/footer_readout_test.go b/native/cmd/lich-native/footer_readout_test.go index 0e6d6ec81..d053348ae 100644 --- a/native/cmd/lich-native/footer_readout_test.go +++ b/native/cmd/lich-native/footer_readout_test.go @@ -16,6 +16,8 @@ import ( "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" + + "github.com/lichdotdev/lich/api" ) func TestFormatCost(t *testing.T) { @@ -328,7 +330,8 @@ func attachBackend(t *testing.T, picked backend.Attachment, writes *[][]string) _ = 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) diff --git a/native/cmd/lich-native/gates_click_test.go b/native/cmd/lich-native/gates_click_test.go index 03e31b187..24dfb268c 100644 --- a/native/cmd/lich-native/gates_click_test.go +++ b/native/cmd/lich-native/gates_click_test.go @@ -214,7 +214,7 @@ func TestGitMissingCheckAgainRereadsThePathThenVerifies(t *testing.T) { 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() { @@ -283,7 +283,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) } @@ -340,7 +340,7 @@ func TestPluginUpdateToastButtons(t *testing.T) { toast := u.pluginUpdateToast(pluginGateAction{kind: pluginActionUpdate, version: "0.9.0", 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() @@ -365,7 +365,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/sidebar_prefs_test.go b/native/cmd/lich-native/sidebar_prefs_test.go index 1c47c9f39..19fd41549 100644 --- a/native/cmd/lich-native/sidebar_prefs_test.go +++ b/native/cmd/lich-native/sidebar_prefs_test.go @@ -74,7 +74,9 @@ func (f *sidebarBackend) answer(method string, args []json.RawMessage) any { f.settings["appearance.theme@"] = opts.ID return nil case "patchnotes.Current": - return backend.PatchNotes{Version: "1.0.0", Groups: []backend.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": var opts api.FollowSystemOptions _ = json.Unmarshal(args[0], &opts) From bff5604f8c29dbf25283a443a8e2a48d8aa91735 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:34:47 -0300 Subject: [PATCH 06/13] refactor(rpc): declare the profile services and quota in the api contract --- api/api_test.go | 5 +- api/profiles.go | 161 ++++++++++++++++++ api/quota.go | 63 +++++++ api/testdata/contract.golden | 134 +++++++++++++++ internal/claudeprofile/service.go | 35 +--- internal/codexprofile/service.go | 31 +--- internal/ompprofile/service.go | 18 +- internal/profilestore/look.go | 14 +- internal/profilestore/store.go | 18 +- internal/quota/quota.go | 45 +---- internal/routes/profiles.go | 89 ++++++++++ internal/routes/routes_test.go | 39 +++++ internal/xdgprofile/service.go | 12 +- main.go | 17 +- native/backend/footer.go | 26 +-- native/backend/footer_test.go | 2 +- native/backend/profile.go | 142 +++++++-------- native/backend/profile_test.go | 28 +-- .../lich-native/profiles_xdg_linux_test.go | 2 +- .../cmd/lich-native/settings_profiles_test.go | 12 +- 20 files changed, 616 insertions(+), 277 deletions(-) create mode 100644 api/profiles.go create mode 100644 api/quota.go create mode 100644 internal/routes/profiles.go diff --git a/api/api_test.go b/api/api_test.go index 229e8afce..645fc60bf 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -121,8 +121,9 @@ func (r *renderer) define(t reflect.Type) string { if !wired { continue } - if field.Anonymous { - panic(fmt.Sprintf("api: %s embeds %s; the golden does not render embedded fields", t.Name(), field.Type)) + if field.Anonymous && field.Tag.Get("json") == "" { + fmt.Fprintf(&b, "\t...%s\n", r.ref(field.Type)) + continue } mark := "" if omitempty { 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/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/testdata/contract.golden b/api/testdata/contract.golden index 79e46a526..8905831d4 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -3,13 +3,55 @@ 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 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 @@ -87,6 +129,64 @@ AppUpdateStatus { 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 []uint8 +} + +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 @@ -136,6 +236,24 @@ BinaryCheck { 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 @@ -527,6 +645,12 @@ TrayLabels { quit string } +ProfileUpdate { + name,omitempty string + color,omitempty *string + image,omitempty *string +} + PatchNotesHighlight { kind string text string @@ -537,6 +661,16 @@ PatchNotesGroup { items []string } +QuotaWindow { + label string + seconds int + percent int + resetsAt,omitempty string + active,omitempty bool + lockedReason,omitempty string + ahead,omitempty bool +} + CostTotal { project string sessions int diff --git a/internal/claudeprofile/service.go b/internal/claudeprofile/service.go index 984d7d69f..65d96d231 100644 --- a/internal/claudeprofile/service.go +++ b/internal/claudeprofile/service.go @@ -14,6 +14,7 @@ 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" @@ -80,23 +81,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. @@ -190,13 +175,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 +225,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/codexprofile/service.go b/internal/codexprofile/service.go index e4d0e96b7..d7866fe7d 100644 --- a/internal/codexprofile/service.go +++ b/internal/codexprofile/service.go @@ -16,6 +16,8 @@ import ( "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/winexec" + + "github.com/lichdotdev/lich/api" ) const ( @@ -92,19 +94,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. @@ -189,12 +179,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 +204,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/ompprofile/service.go b/internal/ompprofile/service.go index 17223c068..775fbeedc 100644 --- a/internal/ompprofile/service.go +++ b/internal/ompprofile/service.go @@ -15,6 +15,8 @@ import ( "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/winexec" + + "github.com/lichdotdev/lich/api" ) const ( @@ -56,21 +58,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 diff --git a/internal/profilestore/look.go b/internal/profilestore/look.go index ef605923f..ec8dec519 100644 --- a/internal/profilestore/look.go +++ b/internal/profilestore/look.go @@ -15,6 +15,8 @@ import ( xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" + + "github.com/lichdotdev/lich/api" ) // ChangedEvent is the event a profile's update emits once it is written: its @@ -27,17 +29,7 @@ type Changed struct { Profile Profile `json:"profile"` } -// 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/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/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/routes_test.go b/internal/routes/routes_test.go index 3d5a2eb6e..7771ca419 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -10,6 +10,7 @@ import ( "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) { @@ -57,6 +58,44 @@ func TestHandleAppServicesCoverEveryMethod(t *testing.T) { } } +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 diff --git a/internal/xdgprofile/service.go b/internal/xdgprofile/service.go index ac00ea986..4cdd403db 100644 --- a/internal/xdgprofile/service.go +++ b/internal/xdgprofile/service.go @@ -13,6 +13,8 @@ import ( "github.com/lichdotdev/lich/internal/profilestore" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/winexec" + + "github.com/lichdotdev/lich/api" ) const ( @@ -56,15 +58,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. diff --git a/main.go b/main.go index d3f2cb862..867c3b2ac 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,8 @@ import ( "github.com/lichdotdev/lich/internal/tray" "github.com/lichdotdev/lich/internal/xdgprofile" "github.com/ncruces/zenity" + + "github.com/lichdotdev/lich/api" ) // changelog is embedded so the app can show a "what's new" popup after an @@ -287,18 +289,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() { @@ -307,7 +314,7 @@ 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) } routes.AppUpdate(dispatcher, appupdate.New(version, coord.Install, hub.Emit)) routes.PatchNotes(dispatcher, patchnotes.New(version, changelog, changelogTranslations)) @@ -382,7 +389,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 { diff --git a/native/backend/footer.go b/native/backend/footer.go index e945fdc2b..140a43bd6 100644 --- a/native/backend/footer.go +++ b/native/backend/footer.go @@ -6,28 +6,9 @@ import ( "github.com/lichdotdev/lich/api" ) -// 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"` -} +type QuotaWindow = api.QuotaWindow -// 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"` -} +type QuotaPlan = api.QuotaPlan type Attachment = api.DropAttachment @@ -35,8 +16,7 @@ type Attachment = api.DropAttachment // 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) + return callMethod(ctx, c, api.QuotaPlans, api.QuotaPlansOptions{SessionID: sessionID}) } // HandsOn is how long session id has been worked on, in whole seconds. diff --git a/native/backend/footer_test.go b/native/backend/footer_test.go index cf4e98749..e79776184 100644 --- a/native/backend/footer_test.go +++ b/native/backend/footer_test.go @@ -26,7 +26,7 @@ func TestFooterCallsSendPositionalArgs(t *testing.T) { 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 }}, diff --git a/native/backend/profile.go b/native/backend/profile.go index 9c8630f13..7c0015bd0 100644 --- a/native/backend/profile.go +++ b/native/backend/profile.go @@ -2,7 +2,10 @@ package backend import ( "context" + "errors" "strings" + + "github.com/lichdotdev/lich/api" ) const ( @@ -26,56 +29,13 @@ const ( // 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"` -} +type Profile = api.ProfileInfo + +type ProfileUpdate = api.ProfileUpdate + +type ProfilePlugin = api.ProfilePlugin + +type ProfilePluginResult = api.ProfilePluginResult // ProfileLogin mirrors claudeprofile.LoginEvent and codexprofile.LoginEvent: // Output (and URL, when that text holds the sign-in address) while the CLI @@ -113,102 +73,132 @@ type ProfileMoved struct { // 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. +// plan. A call the provider has no method for fails with ErrNoProfileCall. type ProfileAPI struct { - c *Client - service string - plan string + 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, service: "claudeprofile", plan: "quota.ProfilePlan"} + 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, service: "codexprofile", plan: "quota.CodexProfilePlan"} + 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, service: "ompprofile"} + 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, service: kind + "profile"} + 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) { - var list []Profile - return list, a.c.Call(ctx, a.service+".Profiles", &list) + 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) { - var p Profile - return p, a.c.Call(ctx, a.service+".CreateProfile", &p, name) + 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) { - var p Profile - return p, a.c.Call(ctx, a.service+".UpdateProfile", &p, slug, u) + 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 { - return a.c.Call(ctx, a.service+".DeleteProfile", nil, slug) + _, 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) { - var list []ProfilePlugin - return list, a.c.Call(ctx, a.service+".Plugins", &list, slug) + 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) { - var results []ProfilePluginResult - return results, a.c.Call(ctx, a.service+".InstallPlugins", &results, slug, keys) + 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 { - return a.c.Call(ctx, a.service+".Login", nil, slug) + 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 { - return a.c.Call(ctx, a.service+".LoginCode", nil, slug, code) + 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 { - return a.c.Call(ctx, a.service+".CancelLogin", nil, slug) + 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) { - var command string - return command, a.c.Call(ctx, a.service+".LoginCommand", &command, slug) + 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) { - var plan QuotaPlan - return plan, a.c.Call(ctx, a.plan, &plan, slug) + 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 diff --git a/native/backend/profile_test.go b/native/backend/profile_test.go index 432112a64..cadf3b5fb 100644 --- a/native/backend/profile_test.go +++ b/native/backend/profile_test.go @@ -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,7 +79,7 @@ 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 }}, diff --git a/native/cmd/lich-native/profiles_xdg_linux_test.go b/native/cmd/lich-native/profiles_xdg_linux_test.go index a227cc4ce..fa8a5d264 100644 --- a/native/cmd/lich-native/profiles_xdg_linux_test.go +++ b/native/cmd/lich-native/profiles_xdg_linux_test.go @@ -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) } diff --git a/native/cmd/lich-native/settings_profiles_test.go b/native/cmd/lich-native/settings_profiles_test.go index a065bbb97..97533fb5a 100644 --- a/native/cmd/lich-native/settings_profiles_test.go +++ b/native/cmd/lich-native/settings_profiles_test.go @@ -135,7 +135,7 @@ func TestNewProfileBringsItsPluginsOnlyOnceTheLoginIsThrough(t *testing.T) { 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) } @@ -152,7 +152,7 @@ func TestCancellingAWaitingLoginEndsItAndClosesTheDialog(t *testing.T) { 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, backend.ProfileLogin{Profile: "work", Done: true, Cancelled: true, Error: "login cancelled"}) if f.view().open { @@ -193,7 +193,7 @@ func TestASubmittedCodeShowsItIsBeingChecked(t *testing.T) { 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") @@ -382,7 +382,7 @@ func TestADeletedProfileClosesTheDialogAndHasSettingsReadAgain(t *testing.T) { 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()) } } @@ -477,7 +477,7 @@ func TestACodexProfileBringsPluginsAndHasNoCodeToPaste(t *testing.T) { 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) } @@ -567,7 +567,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) } From e97a32c7495052e1faaa7d608878274e79946dd8 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:38:14 -0300 Subject: [PATCH 07/13] refactor(rpc): declare the terminal service in the api contract --- api/terminal.go | 152 ++++++++++++++++++ api/testdata/contract.golden | 100 ++++++++++++ internal/routes/routes_test.go | 1 + internal/routes/terminal.go | 49 ++++++ internal/terminal/said.go | 11 +- internal/terminal/search.go | 10 +- internal/terminal/turnstates.go | 19 +-- internal/terminal/usage.go | 33 +--- main.go | 2 +- native/backend/api.go | 22 ++- native/backend/footer.go | 6 +- native/backend/footer_test.go | 4 +- native/backend/palette.go | 17 +- native/backend/palette_test.go | 2 +- native/backend/profile.go | 3 +- native/backend/profile_test.go | 2 +- native/backend/session.go | 18 ++- native/backend/turnstates.go | 23 +-- native/backend/turnstates_test.go | 4 +- native/backend/window.go | 9 +- native/backend/worktree.go | 9 +- native/cmd/lich-native/exit_toast_test.go | 2 +- native/cmd/lich-native/footer_readout_test.go | 7 +- native/cmd/lich-native/model.go | 10 +- native/cmd/lich-native/paste_unfold_test.go | 4 +- native/cmd/lich-native/turnstates_test.go | 8 +- 26 files changed, 390 insertions(+), 137 deletions(-) create mode 100644 api/terminal.go create mode 100644 internal/routes/terminal.go 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 index 8905831d4..25074118a 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -90,6 +90,22 @@ 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 @@ -573,6 +589,79 @@ QuitOptions { ShowWindowOptions { } +SessionIDOptions { + id string +} + +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 @@ -679,6 +768,17 @@ CostTotal { costUsd float64 } +SessionUsage { + id string + percent int + tokens int + window int + model string + effort string + costUsd,omitempty *float64 + costMiss,omitempty string +} + ThemeSource { url string version string diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index 7771ca419..f9d7d0e0d 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -51,6 +51,7 @@ func TestHandleAppServicesCoverEveryMethod(t *testing.T) { "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) }, } { d := rpc.New() route(d) 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/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/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/turnstates.go b/internal/terminal/turnstates.go index 3c9d28573..bb459c3fd 100644 --- a/internal/terminal/turnstates.go +++ b/internal/terminal/turnstates.go @@ -4,6 +4,8 @@ import ( "slices" "sync" "time" + + "github.com/lichdotdev/lich/api" ) // statusBook is the last session-status payload published for each session. @@ -56,22 +58,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 diff --git a/internal/terminal/usage.go b/internal/terminal/usage.go index 366955666..1d8da5ce0 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -4,6 +4,7 @@ import ( "log/slog" "path/filepath" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -11,37 +12,7 @@ import ( // emitted after a turn ends. Global like the other session events. const usageEventName = "session-usage" -// 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 diff --git a/main.go b/main.go index 867c3b2ac..ebe88e8fe 100644 --- a/main.go +++ b/main.go @@ -265,7 +265,7 @@ 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) + routes.Terminal(dispatcher, term) routes.Drop(dispatcher, drops) dispatcher.Register("project", proj) plugins := agentplugin.New(db) diff --git a/native/backend/api.go b/native/backend/api.go index f5c0fedd3..de0cef681 100644 --- a/native/backend/api.go +++ b/native/backend/api.go @@ -4,6 +4,8 @@ import ( "context" "encoding/base64" "fmt" + + "github.com/lichdotdev/lich/api" ) // Project mirrors store.Project (internal/store/store.go), trimmed to what the @@ -84,8 +86,8 @@ func (c *Client) SetActiveSession(ctx context.Context, projectID, sessionID stri // 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 { + b64, err := callMethod(ctx, c, api.TerminalReplay, api.SessionIDOptions{ID: id}) + if err != nil { return nil, err } data, err := base64.StdEncoding.DecodeString(b64) @@ -109,25 +111,33 @@ type ConversationReported struct { // 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) + _, 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 { - return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, parent, true, cols, rows) + _, 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 { - return c.Call(ctx, "terminal.Resize", nil, id, cols, rows) + _, 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 { - return c.Call(ctx, "terminal.SetVisible", nil, id, visible) + _, err := callMethod(ctx, c, api.TerminalSetVisible, api.SetVisibleOptions{ID: id, Visible: visible}) + return err } // Diff returns a checkout's branch and change counts. diff --git a/native/backend/footer.go b/native/backend/footer.go index 140a43bd6..918679ac8 100644 --- a/native/backend/footer.go +++ b/native/backend/footer.go @@ -21,8 +21,7 @@ func (c *Client) QuotaPlans(ctx context.Context, sessionID string) ([]QuotaPlan, // 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) + return callMethod(ctx, c, api.TerminalHandsOn, api.SessionIDOptions{ID: id}) } // Attach opens the backend's file chooser for session sessionID. root is the @@ -34,7 +33,8 @@ func (c *Client) Attach(ctx context.Context, sessionID, root string, confined bo // 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) + _, 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. diff --git a/native/backend/footer_test.go b/native/backend/footer_test.go index e79776184..ec1c69d22 100644 --- a/native/backend/footer_test.go +++ b/native/backend/footer_test.go @@ -30,7 +30,7 @@ func TestFooterCallsSendPositionalArgs(t *testing.T) { _, 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 }}, @@ -38,7 +38,7 @@ func TestFooterCallsSendPositionalArgs(t *testing.T) { _, 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 { diff --git a/native/backend/palette.go b/native/backend/palette.go index 141f4eb76..c633d8484 100644 --- a/native/backend/palette.go +++ b/native/backend/palette.go @@ -1,6 +1,10 @@ package backend -import "context" +import ( + "context" + + "github.com/lichdotdev/lich/api" +) // ClosedSession mirrors store.ClosedSession: a parked session as the command // palette's History tab lists it. ParkedBranch is the branch recorded at the @@ -50,13 +54,7 @@ type ExternalSession struct { 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"` -} +type TranscriptMatch = api.TranscriptMatch // RecentProjectsMatching is RecentProjects narrowed to the closed projects // whose name or path hold every word of term. @@ -104,8 +102,7 @@ func (c *Client) ForgetSession(ctx context.Context, sessionID string) error { // 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) + 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 diff --git a/native/backend/palette_test.go b/native/backend/palette_test.go index 1ac9fedae..097cfdfb5 100644 --- a/native/backend/palette_test.go +++ b/native/backend/palette_test.go @@ -36,7 +36,7 @@ func TestPaletteCallsSendPositionalArgs(t *testing.T) { {"ForgetSession", "store.ForgetSession", `"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 }}, diff --git a/native/backend/profile.go b/native/backend/profile.go index 7c0015bd0..870850627 100644 --- a/native/backend/profile.go +++ b/native/backend/profile.go @@ -205,7 +205,8 @@ func (a ProfileAPI) Plan(ctx context.Context, slug string) (QuotaPlan, error) { // 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) + _, 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 diff --git a/native/backend/profile_test.go b/native/backend/profile_test.go index cadf3b5fb..9edc91d1f 100644 --- a/native/backend/profile_test.go +++ b/native/backend/profile_test.go @@ -83,7 +83,7 @@ func TestProfileCallsSendPositionalArgs(t *testing.T) { _, 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/session.go b/native/backend/session.go index b90449cfe..87414c1d7 100644 --- a/native/backend/session.go +++ b/native/backend/session.go @@ -1,6 +1,10 @@ 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 @@ -11,7 +15,8 @@ func (c *Client) CloseSession(ctx context.Context, projectID, sessionID, activeI // 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. @@ -37,22 +42,19 @@ func (c *Client) SetSessionColor(ctx context.Context, sessionID, color string) e // 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 diff --git a/native/backend/turnstates.go b/native/backend/turnstates.go index d6cee6be0..eb3e21c62 100644 --- a/native/backend/turnstates.go +++ b/native/backend/turnstates.go @@ -2,29 +2,14 @@ package backend import ( "context" - "encoding/json" + + "github.com/lichdotdev/lich/api" ) -// 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"` -} +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) { - var states []TurnState - return states, c.Call(ctx, "terminal.TurnStates", &states) + return callMethod(ctx, c, api.TerminalTurnStates, api.None{}) } diff --git a/native/backend/turnstates_test.go b/native/backend/turnstates_test.go index 773c7017e..9175a3e5f 100644 --- a/native/backend/turnstates_test.go +++ b/native/backend/turnstates_test.go @@ -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/window.go b/native/backend/window.go index 0f4c4731c..8d33280f1 100644 --- a/native/backend/window.go +++ b/native/backend/window.go @@ -8,8 +8,7 @@ import ( // 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 @@ -44,8 +43,6 @@ func (c *Client) Notify(ctx context.Context, summary, detail string) error { // 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 index 5a754bef3..b90a4c761 100644 --- a/native/backend/worktree.go +++ b/native/backend/worktree.go @@ -1,6 +1,10 @@ package backend -import "context" +import ( + "context" + + "github.com/lichdotdev/lich/api" +) // Worktree mirrors project.Worktree. type Worktree struct { @@ -77,6 +81,5 @@ func (c *Client) AddSessionWithSandbox(ctx context.Context, projectID, sessionID // 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) + return callMethod(ctx, c, api.TerminalReady, api.SessionIDOptions{ID: id}) } diff --git a/native/cmd/lich-native/exit_toast_test.go b/native/cmd/lich-native/exit_toast_test.go index 6c4b290de..2e6f699a6 100644 --- a/native/cmd/lich-native/exit_toast_test.go +++ b/native/cmd/lich-native/exit_toast_test.go @@ -151,7 +151,7 @@ func TestRestartStartsTheConversationAsked(t *testing.T) { 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 { diff --git a/native/cmd/lich-native/footer_readout_test.go b/native/cmd/lich-native/footer_readout_test.go index d053348ae..191cac40b 100644 --- a/native/cmd/lich-native/footer_readout_test.go +++ b/native/cmd/lich-native/footer_readout_test.go @@ -336,10 +336,9 @@ func attachBackend(t *testing.T, picked backend.Attachment, writes *[][]string) } _ = 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) diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index 8017eae14..42a553659 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -247,10 +247,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) } } diff --git a/native/cmd/lich-native/paste_unfold_test.go b/native/cmd/lich-native/paste_unfold_test.go index 4a6ab781b..91b467d2e 100644 --- a/native/cmd/lich-native/paste_unfold_test.go +++ b/native/cmd/lich-native/paste_unfold_test.go @@ -94,11 +94,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") } diff --git a/native/cmd/lich-native/turnstates_test.go b/native/cmd/lich-native/turnstates_test.go index 8849d4a05..ff58e962e 100644 --- a/native/cmd/lich-native/turnstates_test.go +++ b/native/cmd/lich-native/turnstates_test.go @@ -11,6 +11,8 @@ import ( "time" "github.com/lichdotdev/lich/native/backend" + + "github.com/lichdotdev/lich/api" ) func turnState(id, state string) backend.TurnState { @@ -149,8 +151,12 @@ func fakeTurnStates(t *testing.T, states []backend.TurnState) *backend.Client { } func TestModelHydratesStatusAndUsageThenFollowsNewerEvents(t *testing.T) { + 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: json.RawMessage(opusReport), Cwd: "/elsewhere"} + Usage: &usage, Cwd: "/elsewhere"} m := newTestModel(fakeTurnStates(t, []backend.TurnState{busy})) toasts := 0 m.notify = func(statusReport) { toasts++ } From c09ae9e2083b13113c5d61b7b00b688f4b904bd1 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:43:36 -0300 Subject: [PATCH 08/13] refactor(rpc): declare the store service in the api contract --- .github/workflows/release.yml | 2 +- api/store.go | 354 +++++++++++++++++- api/testdata/contract.golden | 235 +++++++++++- internal/routes/store.go | 92 +++++ internal/store/external.go | 19 +- internal/store/history.go | 58 +-- internal/store/store.go | 90 +---- main.go | 1 - native/backend/api.go | 62 +-- native/backend/footer.go | 3 +- native/backend/footer_test.go | 2 +- native/backend/palette.go | 66 +--- native/backend/palette_test.go | 10 +- native/backend/projects.go | 26 +- native/backend/pulls.go | 3 +- native/backend/session.go | 18 +- native/backend/store.go | 18 +- native/backend/store_test.go | 16 +- native/backend/worktree.go | 6 +- native/cmd/lich-native/cardstate_test.go | 6 +- native/cmd/lich-native/footer_layout_test.go | 8 +- native/cmd/lich-native/gates_click_test.go | 14 +- native/cmd/lich-native/model_layouts_test.go | 10 +- native/cmd/lich-native/model_test.go | 6 +- native/cmd/lich-native/project_events_test.go | 2 +- native/cmd/lich-native/projects_test.go | 6 +- native/cmd/lich-native/sidebar_blocks_test.go | 6 +- .../cmd/lich-native/sidebar_compose_test.go | 4 +- native/cmd/lich-native/sidebar_prefs_test.go | 16 +- 29 files changed, 807 insertions(+), 352 deletions(-) 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/api/store.go b/api/store.go index a38050bb6..9233db631 100644 --- a/api/store.go +++ b/api/store.go @@ -1,6 +1,196 @@ package api -var StoreCostTotals = define[CostTotalsOptions, CostReport]("store.CostTotals") +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 @@ -65,3 +255,165 @@ type CostTotal struct { 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/testdata/contract.golden b/api/testdata/contract.golden index 25074118a..8948ac352 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -76,7 +76,35 @@ 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 @@ -538,6 +566,68 @@ WorktreeCheckout { 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 +} + +ProjectIDOptions { + id string +} + +ParkSessionOptions { + projectId string + sessionId string + activeId string +} + +SearchTermOptions { + term string +} + +ClosedHistory { + sessions []ParkedSession + total int + indexing int +} + CostTotalsOptions { project string provider string @@ -555,6 +645,132 @@ CostReport { 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 } @@ -589,10 +805,6 @@ QuitOptions { ShowWindowOptions { } -SessionIDOptions { - id string -} - ContinueInOptions { id string profile string @@ -760,6 +972,21 @@ QuotaWindow { 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 diff --git a/internal/routes/store.go b/internal/routes/store.go index c1c634041..743faec02 100644 --- a/internal/routes/store.go +++ b/internal/routes/store.go @@ -10,5 +10,97 @@ import ( // 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/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/store.go b/internal/store/store.go index 63b40c249..bfc476cd1 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 @@ -349,85 +351,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 +487,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/main.go b/main.go index ebe88e8fe..64727bc4c 100644 --- a/main.go +++ b/main.go @@ -318,7 +318,6 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub } routes.AppUpdate(dispatcher, appupdate.New(version, coord.Install, hub.Emit)) routes.PatchNotes(dispatcher, patchnotes.New(version, changelog, changelogTranslations)) - dispatcher.Register("store", db) 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 diff --git a/native/backend/api.go b/native/backend/api.go index de0cef681..d82f4ac0e 100644 --- a/native/backend/api.go +++ b/native/backend/api.go @@ -8,50 +8,9 @@ import ( "github.com/lichdotdev/lich/api" ) -// 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"` -} +type Project = api.Project + +type Session = api.Session // DiffStats mirrors project.DiffStats. type DiffStats struct { @@ -64,23 +23,25 @@ type DiffStats struct { // 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) + 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 { - return c.Call(ctx, "store.AddProject", nil, id, name, path) + _, 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 { - return c.Call(ctx, "store.AddSession", nil, projectID, sessionID, label, kind, path, nextSeq, "") + _, 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 { - return c.Call(ctx, "store.SetActiveSession", nil, projectID, sessionID) + _, 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 @@ -188,8 +149,7 @@ func (c *Client) RevertLines(ctx context.Context, path, rel string, lines []Reve // 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) + 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 diff --git a/native/backend/footer.go b/native/backend/footer.go index 918679ac8..fb5ac8213 100644 --- a/native/backend/footer.go +++ b/native/backend/footer.go @@ -39,5 +39,6 @@ func (c *Client) Write(ctx context.Context, id, data string) error { // 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) + _, err := callMethod(ctx, c, api.StoreSetSetting, api.SetSettingOptions{Key: key, ProjectID: scope, Value: value}) + return err } diff --git a/native/backend/footer_test.go b/native/backend/footer_test.go index ec1c69d22..52d6bcb7b 100644 --- a/native/backend/footer_test.go +++ b/native/backend/footer_test.go @@ -41,7 +41,7 @@ func TestFooterCallsSendPositionalArgs(t *testing.T) { {"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/backend/palette.go b/native/backend/palette.go index c633d8484..c429487d6 100644 --- a/native/backend/palette.go +++ b/native/backend/palette.go @@ -6,96 +6,52 @@ import ( "github.com/lichdotdev/lich/api" ) -// 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"` -} +type ClosedSession = api.ParkedSession -// 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"` -} +type ClosedHistory = api.ClosedHistory -// 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"` -} +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) { - var rows []RecentProject - return rows, c.Call(ctx, "store.RecentProjects", &rows, term) + 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) { - var n int - return n, c.Call(ctx, "store.ClosedProjectCount", &n, term) + 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) { - var h ClosedHistory - return h, c.Call(ctx, "store.ClosedSessions", &h, term) + 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) { - var rows []ExternalSession - return rows, c.Call(ctx, "store.ExternalSessions", &rows) + 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 { - return c.Call(ctx, "store.AdoptExternalSession", nil, projectID, sessionID, kind, path, providerSessionID, label) + _, 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 { - return c.Call(ctx, "store.ForgetSession", nil, sessionID) + _, err := callMethod(ctx, c, api.StoreForgetSession, api.SessionIDOptions{ID: sessionID}) + return err } // SearchTranscripts finds which of the sessions ids have talked about query diff --git a/native/backend/palette_test.go b/native/backend/palette_test.go index 097cfdfb5..eebf361a7 100644 --- a/native/backend/palette_test.go +++ b/native/backend/palette_test.go @@ -14,15 +14,15 @@ func TestPaletteCallsSendPositionalArgs(t *testing.T) { 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,10 +30,10 @@ 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", `{"ids":["s1","s2"],"query":"flaky"}`, []TranscriptMatch{}, func(c *Client) error { diff --git a/native/backend/projects.go b/native/backend/projects.go index 1bc081cd9..07b7edfb4 100644 --- a/native/backend/projects.go +++ b/native/backend/projects.go @@ -1,35 +1,33 @@ 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. diff --git a/native/backend/pulls.go b/native/backend/pulls.go index 3fececabc..ff8b40d46 100644 --- a/native/backend/pulls.go +++ b/native/backend/pulls.go @@ -181,7 +181,8 @@ func (c *Client) RemoveWorktree(ctx context.Context, projectPath, path string, f // 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) + _, 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. diff --git a/native/backend/session.go b/native/backend/session.go index 87414c1d7..86f266c03 100644 --- a/native/backend/session.go +++ b/native/backend/session.go @@ -10,7 +10,8 @@ import ( // 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. @@ -21,23 +22,27 @@ func (c *Client) CloseTerminal(ctx context.Context, id string) error { // 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. @@ -62,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/backend/store.go b/native/backend/store.go index 69637686c..2993732ab 100644 --- a/native/backend/store.go +++ b/native/backend/store.go @@ -11,39 +11,43 @@ 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 diff --git a/native/backend/store_test.go b/native/backend/store_test.go index aee463bc1..bd8f4d058 100644 --- a/native/backend/store_test.go +++ b/native/backend/store_test.go @@ -15,17 +15,17 @@ func TestStoreCallsSendPositionalArgs(t *testing.T) { 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 { @@ -40,17 +40,17 @@ func TestStoreCallsSendPositionalArgs(t *testing.T) { _, 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 }}, diff --git a/native/backend/worktree.go b/native/backend/worktree.go index b90a4c761..62f0d945e 100644 --- a/native/backend/worktree.go +++ b/native/backend/worktree.go @@ -69,14 +69,14 @@ func (c *Client) Issue(ctx context.Context, path string, number int) (Issue, err // 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) + 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 { - return c.Call(ctx, "store.AddSession", nil, projectID, sessionID, label, kind, path, nextSeq, sandbox) + _, 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. diff --git a/native/cmd/lich-native/cardstate_test.go b/native/cmd/lich-native/cardstate_test.go index 811852876..1258f7d1e 100644 --- a/native/cmd/lich-native/cardstate_test.go +++ b/native/cmd/lich-native/cardstate_test.go @@ -12,6 +12,8 @@ import ( "time" "github.com/lichdotdev/lich/native/backend" + + "github.com/lichdotdev/lich/api" ) func TestCardWearsTheLiveAgentUntilItLeaves(t *testing.T) { @@ -75,10 +77,10 @@ func fakeUnread(t *testing.T, calls chan<- []any) *backend.Client { 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) diff --git a/native/cmd/lich-native/footer_layout_test.go b/native/cmd/lich-native/footer_layout_test.go index 47fef6f39..90962e45e 100644 --- a/native/cmd/lich-native/footer_layout_test.go +++ b/native/cmd/lich-native/footer_layout_test.go @@ -14,6 +14,8 @@ import ( "testing" "github.com/lichdotdev/lich/native/backend" + + "github.com/lichdotdev/lich/api" ) // fakeSettings serves store.GetSetting and store.SetSetting from an in-memory @@ -27,7 +29,7 @@ type fakeSettings struct { 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 +40,9 @@ func (f *fakeSettings) client(t *testing.T) *backend.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) diff --git a/native/cmd/lich-native/gates_click_test.go b/native/cmd/lich-native/gates_click_test.go index 24dfb268c..d308239ab 100644 --- a/native/cmd/lich-native/gates_click_test.go +++ b/native/cmd/lich-native/gates_click_test.go @@ -25,6 +25,8 @@ import ( "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" + + "github.com/lichdotdev/lich/api" ) // gateBackend answers every RPC the startup gates make and reports each one @@ -255,7 +257,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) { @@ -297,7 +299,7 @@ func TestUpdateToastLaterRemembersTheRelease(t *testing.T) { u, b := newGateWindow(t) 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) } } @@ -316,14 +318,14 @@ func TestUpdateToastInstallTypesTheCommandIntoANewShell(t *testing.T) { 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) } } @@ -344,7 +346,7 @@ func TestPluginUpdateToastButtons(t *testing.T) { 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) } } diff --git a/native/cmd/lich-native/model_layouts_test.go b/native/cmd/lich-native/model_layouts_test.go index f2184c2aa..a504b1c66 100644 --- a/native/cmd/lich-native/model_layouts_test.go +++ b/native/cmd/lich-native/model_layouts_test.go @@ -13,6 +13,8 @@ import ( "time" "github.com/lichdotdev/lich/native/backend" + + "github.com/lichdotdev/lich/api" ) // layoutsBackend answers every call with null and keeps the last value @@ -43,14 +45,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) diff --git a/native/cmd/lich-native/model_test.go b/native/cmd/lich-native/model_test.go index b4acd6914..400cfb609 100644 --- a/native/cmd/lich-native/model_test.go +++ b/native/cmd/lich-native/model_test.go @@ -12,6 +12,8 @@ import ( "testing" "github.com/lichdotdev/lich/native/backend" + + "github.com/lichdotdev/lich/api" ) const ( @@ -226,10 +228,10 @@ func fakeState(t *testing.T, projects *[]backend.Project, lastScreen string) *ba *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, backend.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) diff --git a/native/cmd/lich-native/project_events_test.go b/native/cmd/lich-native/project_events_test.go index 53c47e75a..ac8dafc3a 100644 --- a/native/cmd/lich-native/project_events_test.go +++ b/native/cmd/lich-native/project_events_test.go @@ -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"}) { diff --git a/native/cmd/lich-native/projects_test.go b/native/cmd/lich-native/projects_test.go index fd73fcd44..c3c384889 100644 --- a/native/cmd/lich-native/projects_test.go +++ b/native/cmd/lich-native/projects_test.go @@ -142,7 +142,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,8 +164,8 @@ 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) diff --git a/native/cmd/lich-native/sidebar_blocks_test.go b/native/cmd/lich-native/sidebar_blocks_test.go index 6a46ceccf..1de1b99b6 100644 --- a/native/cmd/lich-native/sidebar_blocks_test.go +++ b/native/cmd/lich-native/sidebar_blocks_test.go @@ -122,7 +122,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 +149,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 +172,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_compose_test.go b/native/cmd/lich-native/sidebar_compose_test.go index b74176a50..146a22c15 100644 --- a/native/cmd/lich-native/sidebar_compose_test.go +++ b/native/cmd/lich-native/sidebar_compose_test.go @@ -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_prefs_test.go b/native/cmd/lich-native/sidebar_prefs_test.go index 19fd41549..fd1d3c5df 100644 --- a/native/cmd/lich-native/sidebar_prefs_test.go +++ b/native/cmd/lich-native/sidebar_prefs_test.go @@ -45,16 +45,14 @@ func newSidebarBackend(t *testing.T) (*sidebarBackend, *backend.Client) { 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 From 0df6bc56cbab1b85b17e39ccd19e7e1f3d200e27 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:49:22 -0300 Subject: [PATCH 09/13] refactor(rpc): declare the project service in the api contract --- api/project.go | 699 ++++++++++++++++++ api/testdata/contract.golden | 447 ++++++++++- internal/project/basestatus.go | 12 +- internal/project/branchrules.go | 42 +- internal/project/commitidentity.go | 15 +- internal/project/freshness.go | 15 +- internal/project/issue.go | 11 +- internal/project/pr.go | 95 +-- internal/project/prchanged.go | 10 +- internal/project/prchecks.go | 27 +- internal/project/prlistthreads.go | 8 +- internal/project/project.go | 31 +- internal/project/prreview.go | 15 +- internal/project/prreviewers.go | 14 +- internal/project/prthreads.go | 82 +- internal/project/revertlines.go | 41 +- internal/project/search.go | 23 +- internal/project/tree.go | 16 +- internal/project/worktree.go | 20 +- internal/routes/project.go | 189 +++++ internal/routes/routes_test.go | 1 + main.go | 2 +- native/backend/api.go | 44 +- native/backend/client_test.go | 10 +- native/backend/palette.go | 3 +- native/backend/palette_test.go | 2 +- native/backend/project.go | 62 +- native/backend/projects.go | 14 +- native/backend/pulls.go | 239 ++---- native/backend/store_test.go | 6 +- native/backend/themes.go | 6 +- native/backend/vcs.go | 14 +- native/backend/worktree.go | 45 +- native/cmd/lich-native/dock_code.go | 11 +- native/cmd/lich-native/dock_code_preview.go | 3 +- native/cmd/lich-native/dock_review.go | 4 +- .../cmd/lich-native/dock_review_markdown.go | 5 +- native/cmd/lich-native/dock_review_test.go | 8 +- native/cmd/lich-native/dock_search.go | 19 +- native/cmd/lich-native/dock_search_view.go | 3 +- native/cmd/lich-native/model_test.go | 6 +- 41 files changed, 1560 insertions(+), 759 deletions(-) create mode 100644 api/project.go create mode 100644 internal/routes/project.go 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/testdata/contract.golden b/api/testdata/contract.golden index 8948ac352..3b839a2f9 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -44,6 +44,58 @@ 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 @@ -252,6 +304,292 @@ PatchNotes { 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 { + Patch string + IndexPatch string +} + +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 @@ -608,10 +946,6 @@ AdoptExternalSessionOptions { label string } -ProjectIDOptions { - id string -} - ParkSessionOptions { projectId string sessionId string @@ -962,6 +1296,101 @@ PatchNotesGroup { 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 @@ -1013,6 +1442,16 @@ ThemeSource { Syntax = map[string]SyntaxStyle +PRThreadComment { + id int64 + author string + body string + date string + diffHunk string + canEdit bool + canDelete bool +} + SyntaxStyle { color,omitempty string bold,omitempty bool 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..48133059e 100644 --- a/internal/project/prchanged.go +++ b/internal/project/prchanged.go @@ -1,5 +1,7 @@ package project +import "github.com/lichdotdev/lich/api" + import "time" // PRChangedEvent is what a pull request write through lich reports once GitHub @@ -7,13 +9,7 @@ import "time" // screen that made the change telling the others. const PRChangedEvent = "pull-request-changed" -// 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). func (s *Service) SetEmit(emit func(name string, data any)) { s.emit = emit } 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/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/routes_test.go b/internal/routes/routes_test.go index f9d7d0e0d..89bbbe633 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -52,6 +52,7 @@ func TestHandleAppServicesCoverEveryMethod(t *testing.T) { "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) diff --git a/main.go b/main.go index 64727bc4c..4b3c3795b 100644 --- a/main.go +++ b/main.go @@ -267,7 +267,7 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub drops.SetPicker(proj.PickFile) routes.Terminal(dispatcher, term) routes.Drop(dispatcher, drops) - dispatcher.Register("project", proj) + routes.Project(dispatcher, proj) plugins := agentplugin.New(db) routes.AgentPlugin(dispatcher, plugins) go plugins.RepairRegistrations() diff --git a/native/backend/api.go b/native/backend/api.go index d82f4ac0e..2d2eb71bb 100644 --- a/native/backend/api.go +++ b/native/backend/api.go @@ -12,14 +12,7 @@ type Project = api.Project type Session = api.Session -// 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"` -} +type DiffStats = api.DiffStats // LoadState returns every open project with its sessions. func (c *Client) LoadState(ctx context.Context) ([]Project, error) { @@ -103,47 +96,31 @@ func (c *Client) SetVisible(ctx context.Context, id string, visible bool) error // 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) + 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) { - var s string - return s, c.Call(ctx, "project.DiffText", &s, path) + 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) { - var lines []string - return lines, c.Call(ctx, "project.FileLines", &lines, path, rel, ref, from, to) + return callMethod(ctx, c, api.ProjectFileLines, api.FileLinesOptions{Path: path, Rel: rel, Ref: ref, From: from, To: 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"` -} +type RevertLine = api.RevertLine -// 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"` -} +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) { - var r RevertResult - return r, c.Call(ctx, "project.RevertLines", &r, path, rel, lines) + 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, @@ -155,6 +132,9 @@ func (c *Client) GetSetting(ctx context.Context, key, scope string) (string, err // 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) + 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/backend/client_test.go b/native/backend/client_test.go index 16cac8e22..2948949ac 100644 --- a/native/backend/client_test.go +++ b/native/backend/client_test.go @@ -41,7 +41,7 @@ func fakeBackend(t *testing.T, handle func(method string, args []json.RawMessage func TestCallSendsPositionalArgsAndDecodes(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 @@ -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/backend/palette.go b/native/backend/palette.go index c429487d6..6b67dc368 100644 --- a/native/backend/palette.go +++ b/native/backend/palette.go @@ -64,6 +64,5 @@ func (c *Client) SearchTranscripts(ctx context.Context, ids []string, query stri // 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) + return callMethod(ctx, c, api.ProjectBranchesOf, api.PathsOptions{Paths: paths}) } diff --git a/native/backend/palette_test.go b/native/backend/palette_test.go index eebf361a7..d4d536c5b 100644 --- a/native/backend/palette_test.go +++ b/native/backend/palette_test.go @@ -40,7 +40,7 @@ func TestPaletteCallsSendPositionalArgs(t *testing.T) { _, 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/project.go b/native/backend/project.go index 2595c57f6..20cc863d9 100644 --- a/native/backend/project.go +++ b/native/backend/project.go @@ -6,33 +6,20 @@ import ( "github.com/lichdotdev/lich/api" ) -// 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"` -} +type BaseStatus = api.BaseStatus -// PullRequest mirrors project.PullRequest. -type PullRequest struct { - Number int `json:"number"` - URL string `json:"url"` - State string `json:"state"` -} +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) { - var b *BaseStatus - return b, c.Call(ctx, "project.BaseStatus", &b, path) + 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) { - var pr *PullRequest - return pr, c.Call(ctx, "project.PullRequest", &pr, path) + return callMethod(ctx, c, api.ProjectPullRequest, api.PathOptions{Path: path}) } // OpenExternal opens url in the user's browser. @@ -92,3 +79,44 @@ func (c *Client) SandboxBackend(ctx context.Context) (string, error) { 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/backend/projects.go b/native/backend/projects.go index 07b7edfb4..a74f31d42 100644 --- a/native/backend/projects.go +++ b/native/backend/projects.go @@ -32,25 +32,23 @@ func (c *Client) ClosedProjectCount(ctx context.Context) (int, error) { // 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 index ff8b40d46..3edb55236 100644 --- a/native/backend/pulls.go +++ b/native/backend/pulls.go @@ -10,173 +10,85 @@ import ( // landed on GitHub. const PRChangedEvent = "pull-request-changed" -// PRChanged mirrors project.PRChanged. -type PRChanged struct { - Path string `json:"path"` - Number int `json:"number"` -} +type PRChanged = api.PRChanged -// ChecksRollup mirrors project.ChecksRollup. -type ChecksRollup struct { - Passed int `json:"passed"` - Failed int `json:"failed"` - Pending int `json:"pending"` - Total int `json:"total"` -} +type ChecksRollup = api.ChecksRollup -// 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"` -} +type CheckItem = api.CheckItem -// ThreadTally mirrors project.ThreadTally. -type ThreadTally struct { - Open int `json:"open"` - Total int `json:"total"` -} +type ThreadTally = api.ThreadTally -// 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"` -} +type PRSummary = api.PRSummary -// PRReviewer mirrors project.PRReviewer. -type PRReviewer struct { - Login string `json:"login"` - IsTeam bool `json:"isTeam"` - State string `json:"state"` -} +type PRReviewer = api.PRReviewer -// 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"` -} +type PRCommit = api.PRCommit -// 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"` -} +type PRDetail = api.PRDetail -// CommitPattern mirrors project.CommitPattern. -type CommitPattern struct { - Target string `json:"target"` - Operator string `json:"operator"` - Pattern string `json:"pattern"` - Negate bool `json:"negate"` -} +type CommitPattern = api.CommitPattern -// BranchRules mirrors project.BranchRules. -type BranchRules struct { - AllowedMergeMethods []string `json:"allowedMergeMethods"` - CommitPatterns []CommitPattern `json:"commitPatterns"` - ViewerCanBypass bool `json:"viewerCanBypass"` -} +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) { - var list []PRSummary - return list, c.Call(ctx, "project.ListPullRequests", &list, path, state) + 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) { - var d *PRDetail - return d, c.Call(ctx, "project.PullRequestDetail", &d, path, number) + 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) { - var files []string - return files, c.Call(ctx, "project.PullRequestConflicts", &files, path, number, base, url) + 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) { - var r *BranchRules - return r, c.Call(ctx, "project.BranchRules", &r, path, branch) + 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 { - return c.Call(ctx, "project.MergePullRequest", nil, path, number, method, subject, body, admin) + _, 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 { - return c.Call(ctx, "project.CreatePullRequest", nil, path) + _, 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) { - var wt *Worktree - return wt, c.Call(ctx, "project.CreateWorktreeFromPR", &wt, projectPath, projectID, number) + 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) { - var list []Worktree - return list, c.Call(ctx, "project.ListCheckouts", &list, path) + return callMethod(ctx, c, api.ProjectListCheckouts, api.PathOptions{Path: path}) } func (c *Client) WorktreeAdopted(ctx context.Context, path string) (bool, error) { - var ok bool - return ok, c.Call(ctx, "project.WorktreeAdopted", &ok, path) + return callMethod(ctx, c, api.ProjectWorktreeAdopted, api.PathOptions{Path: path}) } func (c *Client) WorktreeDirty(ctx context.Context, path string) (bool, error) { - var dirty bool - return dirty, c.Call(ctx, "project.WorktreeDirty", &dirty, path) + 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 { - return c.Call(ctx, "project.RemoveWorktree", nil, projectPath, path, force, adoptedAck) + _, 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. @@ -200,139 +112,98 @@ func (c *Client) Submit(ctx context.Context, id, message string) error { return err } -// 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"` -} +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 { - return c.Call(ctx, "project.SubmitReview", nil, path, number, event, body, head, comments) + _, 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) { - var diff string - return diff, c.Call(ctx, "project.PullRequestDiff", &diff, path, number) + 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) { - var oid string - return oid, c.Call(ctx, "project.PullRequestMergeBase", &oid, path, number) + return callMethod(ctx, c, api.ProjectPullRequestMergeBase, api.NumberOptions{Path: path, Number: number}) } -// PRConversation mirrors project.PRConversation. -type PRConversation struct { - HeadRefOID string `json:"headRefOid"` - Reviews []PRReview `json:"reviews"` - Comments []PRComment `json:"comments"` - Threads []PRReviewThread `json:"threads"` -} +type PRConversation = api.PRConversation -// 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"` -} +type PRReview = api.PRReview -// 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"` -} +type PRComment = api.PRComment -// 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"` -} +type PRReviewThread = api.PRReviewThread -// 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"` -} +type PRThreadComment = api.PRThreadComment 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) + 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 { - return c.Call(ctx, "project.ReplyToReviewThread", nil, path, number, commentID, body) + _, 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 { - return c.Call(ctx, "project.EditReviewComment", nil, path, number, commentID, body) + _, 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 { - return c.Call(ctx, "project.DeleteReviewComment", nil, path, number, commentID) + _, 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 { - return c.Call(ctx, "project.EditReviewBody", nil, path, number, reviewID, body) + _, 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 { - return c.Call(ctx, "project.ResolveReviewThread", nil, path, threadID, resolved) + _, 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 { - return c.Call(ctx, "project.CommentOnPullRequest", nil, path, number, body) + _, 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 { - return c.Call(ctx, "project.EditPullRequestComment", nil, path, number, commentID, body) + _, 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 { - return c.Call(ctx, "project.DeletePullRequestComment", nil, path, number, commentID) + _, 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 { - return c.Call(ctx, "project.EditPullRequestTitle", nil, path, number, title) + _, 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 { - return c.Call(ctx, "project.EditPullRequestBody", nil, path, number, body) + _, err := callMethod(ctx, c, api.ProjectEditPullRequestBody, api.PRBodyOptions{Path: path, Number: number, Body: body}) + return err } diff --git a/native/backend/store_test.go b/native/backend/store_test.go index bd8f4d058..65c868b30 100644 --- a/native/backend/store_test.go +++ b/native/backend/store_test.go @@ -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 }}, diff --git a/native/backend/themes.go b/native/backend/themes.go index a65d08d7d..9a4a865e9 100644 --- a/native/backend/themes.go +++ b/native/backend/themes.go @@ -79,13 +79,11 @@ func (c *Client) SaveThemeTemplate(ctx context.Context, path string) error { // 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) + 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) { - var path string - return path, c.Call(ctx, "project.PickSaveFile", &path, title, defaultName) + return callMethod(ctx, c, api.ProjectPickSaveFile, api.PickSaveFileOptions{Title: title, DefaultName: defaultName}) } diff --git a/native/backend/vcs.go b/native/backend/vcs.go index a8d0daa7a..eb54a014e 100644 --- a/native/backend/vcs.go +++ b/native/backend/vcs.go @@ -13,13 +13,7 @@ const ( type BinaryCheck = api.BinaryCheck -// 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"` -} +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) { @@ -50,8 +44,7 @@ func (c *Client) RefreshPath(ctx context.Context) error { // 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) + accounts, err := callMethod(ctx, c, api.ProjectGitHubAccounts, api.None{}) if accounts == nil { accounts = []string{} } @@ -60,6 +53,5 @@ func (c *Client) GitHubAccounts(ctx context.Context) ([]string, error) { // 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) + return callMethod(ctx, c, api.ProjectCommitIdentity, api.PathOptions{Path: path}) } diff --git a/native/backend/worktree.go b/native/backend/worktree.go index 62f0d945e..ce8cacfb2 100644 --- a/native/backend/worktree.go +++ b/native/backend/worktree.go @@ -6,64 +6,39 @@ import ( "github.com/lichdotdev/lich/api" ) -// Worktree mirrors project.Worktree. -type Worktree struct { - Name string `json:"name"` - Path string `json:"path"` - Reused bool `json:"reused,omitempty"` -} +type Worktree = api.Worktree -// 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"` -} +type Branches = api.Branches -// BaseFreshness mirrors project.BaseFreshness. -type BaseFreshness struct { - Upstream string `json:"upstream"` - Ahead int `json:"ahead"` - Behind int `json:"behind"` - FetchError string `json:"fetchError,omitempty"` -} +type BaseFreshness = api.BaseFreshness -// Issue mirrors project.Issue. -type Issue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - URL string `json:"url"` -} +type Issue = api.Issue func (c *Client) ListBranches(ctx context.Context, path string) (Branches, error) { - var b Branches - return b, c.Call(ctx, "project.ListBranches", &b, path) + 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) { - var f BaseFreshness - return f, c.Call(ctx, "project.BaseFreshness", &f, path, branch) + 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) { - var wt *Worktree - return wt, c.Call(ctx, "project.CreateWorktree", &wt, projectPath, projectID, name, base, baseIsRemote) + 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 { - return c.Call(ctx, "project.CarryUncommitted", nil, src, dst) + _, 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) { - var i Issue - return i, c.Call(ctx, "project.Issue", &i, path, number) + return callMethod(ctx, c, api.ProjectIssue, api.NumberOptions{Path: path, Number: number}) } // ReopenWorktreeSession brings back the session parked on path under diff --git a/native/cmd/lich-native/dock_code.go b/native/cmd/lich-native/dock_code.go index 4bbe79d5c..2c106849f 100644 --- a/native/cmd/lich-native/dock_code.go +++ b/native/cmd/lich-native/dock_code.go @@ -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. @@ -166,8 +161,8 @@ func (c *codeTab) fetchTree(gtx C, in dockInput) { } func readTree(ctx context.Context, client *backend.Client, path string) (*treeData, error) { - var l fileListing - if err := client.Call(ctx, "project.Tree", &l, path); err != nil { + 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_preview.go b/native/cmd/lich-native/dock_code_preview.go index b5e631f31..207427efd 100644 --- a/native/cmd/lich-native/dock_code_preview.go +++ b/native/cmd/lich-native/dock_code_preview.go @@ -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) { diff --git a/native/cmd/lich-native/dock_review.go b/native/cmd/lich-native/dock_review.go index 3cc38b0f1..8ec8cd4c0 100644 --- a/native/cmd/lich-native/dock_review.go +++ b/native/cmd/lich-native/dock_review.go @@ -326,7 +326,7 @@ func (p *reviewPanel) revert(rel string, lines []backend.RevertLine) { 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 { + 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)}) } } @@ -378,7 +378,7 @@ 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 { + 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_markdown.go b/native/cmd/lich-native/dock_review_markdown.go index 32cb3a373..3033c1967 100644 --- a/native/cmd/lich-native/dock_review_markdown.go +++ b/native/cmd/lich-native/dock_review_markdown.go @@ -95,11 +95,10 @@ 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() } diff --git a/native/cmd/lich-native/dock_review_test.go b/native/cmd/lich-native/dock_review_test.go index c3f10bb2b..5f4c1ba75 100644 --- a/native/cmd/lich-native/dock_review_test.go +++ b/native/cmd/lich-native/dock_review_test.go @@ -13,6 +13,8 @@ import ( "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" + + "github.com/lichdotdev/lich/api" ) const twoHunks = `diff --git a/a.go b/a.go @@ -220,9 +222,9 @@ func TestARevertsUndoPutsTheLinesBack(t *testing.T) { toast.Action.OnClick() select { case args := <-restored: - var gotUndo backend.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 cfad22aa8..5a792df0a 100644 --- a/native/cmd/lich-native/dock_search_view.go +++ b/native/cmd/lich-native/dock_search_view.go @@ -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/model_test.go b/native/cmd/lich-native/model_test.go index 400cfb609..b132c89f2 100644 --- a/native/cmd/lich-native/model_test.go +++ b/native/cmd/lich-native/model_test.go @@ -27,7 +27,7 @@ const ( 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) @@ -36,12 +36,12 @@ func fakeDiff(t *testing.T, notGit string) *backend.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(backend.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) From 6331d5fe6911f493c7fc52f970b30987e5317fd5 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 00:53:50 -0300 Subject: [PATCH 10/13] refactor(rpc): drop the reflective dispatch, every call goes through the api contract --- CLAUDE.md | 6 +- CONTRIBUTING.md | 10 +- api/api_test.go | 6 ++ api/testdata/contract.golden | 5 +- docs/adding-a-provider.md | 2 +- docs/ceilings.md | 8 +- docs/native-window.md | 5 +- internal/routes/routes.go | 10 ++ internal/rpc/handle_test.go | 22 ---- internal/rpc/rpc.go | 166 +++++------------------------ internal/rpc/rpc_test.go | 121 ++------------------- internal/store/cost_totals_test.go | 8 +- internal/terminal/account.go | 4 +- main.go | 144 ------------------------- main_test.go | 120 --------------------- native/backend/client.go | 45 +++----- native/backend/client_test.go | 8 +- native/backend/footer_test.go | 4 +- native/backend/palette_test.go | 2 +- native/backend/profile_test.go | 2 +- native/backend/store_test.go | 2 +- 21 files changed, 99 insertions(+), 601 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0fc5ccd14..5c4861496 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,9 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here. stay English by design; the What's new dialog reads a release's translations from `changelog/.md`. - 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). A service not moved there yet keeps its - shapes hand-owned in `native/backend`: touch a Go struct's JSON tags and its mirror there moves in the same - change. There is no codegen. + its shape (`go test ./api -update` after a change meant to move it). Its options are one struct, never + positional arguments. The `/events` payloads are still hand-owned in `native/backend`: touch one's JSON tags + and its mirror there moves in the same change. 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 216e4d9f1..9f140228a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,11 +65,11 @@ Then: ```bash for os in linux darwin windows; do GOOS=$os go build ./... && GOOS=$os go vet ./...; done ``` -- **Touched an RPC method's options or result?** Methods declared in `api` are - shared by the backend and the window; run `go test ./api -update` and review - the diff of `api/testdata/contract.golden`. The services not moved there yet - are mirrored by hand in `native/backend`, so the mirror moves in the same - change. There is no codegen. +- **Touched an RPC method's options or result?** Every method 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`. An `/events` + payload is still mirrored by hand in `native/backend`, so the mirror moves in + the same change. 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/api_test.go b/api/api_test.go index 645fc60bf..391c1d0fb 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "flag" "fmt" "maps" @@ -15,6 +16,8 @@ var update = flag.Bool("update", false, "rewrite testdata/contract.golden from t const goldenPath = "testdata/contract.golden" +var marshalerType = reflect.TypeFor[json.Marshaler]() + // TestContractMatchesGolden makes every change to a method's name, options or // result 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 @@ -111,6 +114,9 @@ func (r *renderer) ref(t reflect.Type) string { } func (r *renderer) define(t reflect.Type) string { + if t.Implements(marshalerType) { + return fmt.Sprintf("%s = its own MarshalJSON\n", t.Name()) + } if t.Kind() != reflect.Struct { return fmt.Sprintf("%s = %s\n", t.Name(), r.ref(reflectUnderlying(t))) } diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index 3b839a2f9..f221f14b5 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -559,10 +559,7 @@ RevertLinesOptions { lines []RevertLine } -RevertResult { - Patch string - IndexPatch string -} +RevertResult = its own MarshalJSON SearchOptions { path string diff --git a/docs/adding-a-provider.md b/docs/adding-a-provider.md index 2c695f948..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/backend/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 6c25cc2d5..54d4b40b2 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -555,10 +555,10 @@ work when nobody knows it and that the call site never shows. The mechanism and 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 not yet declared - in `api` (`native/backend`) 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. + `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. What the + declarations do not cover is the `/events` stream: its payloads are mirrored by hand in `native/backend`. - **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`, diff --git a/docs/native-window.md b/docs/native-window.md index 2c50823ad..b2880ef18 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 and 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/routes/routes.go b/internal/routes/routes.go index e656b5329..5dfcaa29c 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -2,6 +2,16 @@ // 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 ( diff --git a/internal/rpc/handle_test.go b/internal/rpc/handle_test.go index 01ee8b58c..89ea76737 100644 --- a/internal/rpc/handle_test.go +++ b/internal/rpc/handle_test.go @@ -83,28 +83,6 @@ func TestHandleHandsOverTheRequestContext(t *testing.T) { } } -// A service moves to api one method at a time: the handled method answers, -// the ones still registered by reflection keep answering beside it. -func TestHandleAnswersBeforeARegisteredServiceOfTheSameName(t *testing.T) { - h := New() - h.Register("fake", fakeService{}) - Handle(h, api.Method[api.None, string]{Name: "fake.Fail"}, func(context.Context, api.None) (string, error) { return "typed", nil }) - if rec := call(t, h, "fake.Fail", `[]`); strings.TrimSpace(rec.Body.String()) != `"typed"` { - t.Errorf("handled method: got %d %q", rec.Code, rec.Body.String()) - } - if rec := call(t, h, "fake.Add", `[2, 3]`); strings.TrimSpace(rec.Body.String()) != "5" { - t.Errorf("registered method beside it: got %d %q", rec.Code, rec.Body.String()) - } -} - -func TestHandleHonorsDeny(t *testing.T) { - h := newTypedHandler() - h.Deny("typed.Echo") - if rec := call(t, h, "typed.Echo", `[{"a":"x"}]`); rec.Code != http.StatusNotFound { - t.Fatalf("denied handled method: want 404, got %d", rec.Code) - } -} - func TestHandleRefusesASecondHandlerForOneMethod(t *testing.T) { h := newTypedHandler() defer func() { diff --git a/internal/rpc/rpc.go b/internal/rpc/rpc.go index ccd348939..e3fc1da9f 100644 --- a/internal/rpc/rpc.go +++ b/internal/rpc/rpc.go @@ -1,18 +1,16 @@ -// 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 // -// A method declared in api is exposed with Handle, which the compiler holds to -// the method's options and result types: the array carries that one options -// object, or nothing. The rest is reflection over registered services — the -// net/rpc pattern — matching the array to the method's parameters by -// position; a method whose first parameter is a context.Context gets the -// request's. Both hand over a context that is done when the caller hangs up. -// 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 ( @@ -23,25 +21,17 @@ import ( "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 { - routes map[string]route - 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 @@ -53,15 +43,10 @@ type badArguments struct{ err error } func (b badArguments) Error() string { return b.err.Error() } func New() *Handler { - return &Handler{ - routes: make(map[string]route), - services: make(map[string]reflect.Value), - denied: make(map[string]bool), - } + return &Handler{routes: make(map[string]route)} } -// Handle exposes fn as m. It answers before a Register of the same service, -// so a service moves to api one method at a time. +// 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)) @@ -105,53 +90,23 @@ func decodeOptions[Req any](body []byte) (Req, error) { return req, fmt.Errorf("want one options object, got %d arguments", len(raw)) } -// Register exposes every exported method of svc under name.. -func (h *Handler) Register(name string, svc any) { - h.services[name] = reflect.ValueOf(svc) -} - -// 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 -} - // ServeHTTP handles one call. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "POST only") return } - name := strings.TrimPrefix(r.URL.Path, "/rpc/") - if route, ok := h.routes[name]; ok && !h.denied[name] { - h.serveRoute(w, r, name, route) - return - } - method, err := h.lookup(name) - if err != nil { - slog.Warn("rpc: unknown method", "method", name, "err", err) - writeError(w, http.StatusNotFound, err.Error()) - return - } - - body, ok := readBody(w, r, name) + 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 } - args, err := decodeArgs(r.Context(), method.Type(), body) + body, err := io.ReadAll(io.LimitReader(r.Body, bodyLimit)) if err != nil { - 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) serveRoute(w http.ResponseWriter, r *http.Request, name string, route route) { - body, ok := readBody(w, r, name) - if !ok { + slog.Warn("rpc: unreadable body", "method", name, "err", err) + writeError(w, http.StatusBadRequest, "unreadable body") return } result, err := route(r.Context(), body) @@ -164,84 +119,11 @@ func (h *Handler) serveRoute(w http.ResponseWriter, r *http.Request, name string writeReply(w, name, result, err) } -func readBody(w http.ResponseWriter, r *http.Request, name string) ([]byte, bool) { - 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 nil, false - } - return body, true -} - -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 -} - -// 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 { - writeReply(w, method, nil, err) - return - } - continue - } - if payload == nil { - payload = result.Interface() - } - } - writeReply(w, method, payload, nil) -} - +// 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) 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/store/cost_totals_test.go b/internal/store/cost_totals_test.go index 178a36e4e..4ca0bd58a 100644 --- a/internal/store/cost_totals_test.go +++ b/internal/store/cost_totals_test.go @@ -8,6 +8,10 @@ import ( "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/rpc" + + "github.com/lichdotdev/lich/api" + + "context" ) // costWorkspace opens a store with two projects and the sessions named, so a @@ -450,7 +454,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/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/main.go b/main.go index 4b3c3795b..fba91b754 100644 --- a/main.go +++ b/main.go @@ -447,7 +447,6 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub // up once the window hands it its words (tray.SetLabels). trayIcon := tray.New(trayIcons(), func() { showWindow(sys, "from the tray") }, coord.Quit, term.LiveCount) routes.Tray(dispatcher, trayIcon) - denyInternal(dispatcher) term.Mount("/rpc/", dispatcher) term.Mount("/drop", http.HandlerFunc(drops.Upload)) term.Mount("/blob", http.HandlerFunc(proj.ServeBlob)) @@ -455,149 +454,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. -// - 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", - "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 diff --git a/main_test.go b/main_test.go index bf96c5d56..03dc7244e 100644 --- a/main_test.go +++ b/main_test.go @@ -2,136 +2,16 @@ 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/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. diff --git a/native/backend/client.go b/native/backend/client.go index 962eaf261..93eebb04c 100644 --- a/native/backend/client.go +++ b/native/backend/client.go @@ -1,4 +1,4 @@ -// Package backend 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. // @@ -113,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 { @@ -144,21 +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} - } - if out == nil { - return nil + return out, &RemoteError{Method: m.Name, Message: failure.Error, Status: resp.StatusCode} } - if err := json.Unmarshal(data, out); err != nil { - return fmt.Errorf("%s: decode result: %w", method, err) + if err := json.Unmarshal(data, &out); err != nil { + return out, fmt.Errorf("%s: decode result: %w", m.Name, err) } - return nil -} - -// callMethod is Call held to the types api declares for m. -func callMethod[Req, Resp any](ctx context.Context, c *Client, m api.Method[Req, Resp], req Req) (Resp, error) { - var out Resp - return out, c.Call(ctx, m.Name, &out, m.Args(req)...) + return out, nil } // RemoteError is a call the backend refused. Message is the backend's own diff --git a/native/backend/client_test.go b/native/backend/client_test.go index 2948949ac..4c8545ead 100644 --- a/native/backend/client_test.go +++ b/native/backend/client_test.go @@ -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,7 +39,7 @@ 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]) != `{"path":"/repo"}` { t.Errorf("got %s %s", method, args) @@ -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) } diff --git a/native/backend/footer_test.go b/native/backend/footer_test.go index 52d6bcb7b..a8c7eac23 100644 --- a/native/backend/footer_test.go +++ b/native/backend/footer_test.go @@ -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,7 +19,7 @@ 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 diff --git a/native/backend/palette_test.go b/native/backend/palette_test.go index d4d536c5b..a89b63ea7 100644 --- a/native/backend/palette_test.go +++ b/native/backend/palette_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestPaletteCallsSendPositionalArgs(t *testing.T) { +func TestPaletteCallsSendTheirOptions(t *testing.T) { ctx := context.Background() cases := []struct { name, method, args string diff --git a/native/backend/profile_test.go b/native/backend/profile_test.go index 9edc91d1f..a56314040 100644 --- a/native/backend/profile_test.go +++ b/native/backend/profile_test.go @@ -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 diff --git a/native/backend/store_test.go b/native/backend/store_test.go index 65c868b30..86dc043a3 100644 --- a/native/backend/store_test.go +++ b/native/backend/store_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestStoreCallsSendPositionalArgs(t *testing.T) { +func TestStoreCallsSendTheirOptions(t *testing.T) { ctx := context.Background() cases := []struct { name, method, args string From 857bdd2a04980edc18ef0084f77fd42bf5911a2c Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 01:02:11 -0300 Subject: [PATCH 11/13] test(api): read the golden as a Windows checkout spells it --- api/api_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/api_test.go b/api/api_test.go index 391c1d0fb..ab9bad01f 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -35,7 +35,10 @@ func TestContractMatchesGolden(t *testing.T) { if err != nil { t.Fatal(err) } - if got != string(want) { + // 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.Errorf("the contract moved; if that is meant, run `go test ./api -update` and review the diff.\ngot:\n%s", got) } } From 60177de4eb6d262df562fb998de984bb6ec08cd7 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 08:10:11 -0300 Subject: [PATCH 12/13] refactor(events): declare the /events payloads in the api contract Every event is an api.Event[T]; the backend emits it with events.Send and the window reads it with backend.Decode, so both compile against one payload type. The golden lists the events and shows RevertResult's wire shape. --- CLAUDE.md | 4 +- CONTRIBUTING.md | 9 +- api/api_test.go | 84 +++++- api/eventpayloads.go | 255 ++++++++++++++++ api/events.go | 282 ++++++++++++++++++ api/testdata/contract.golden | 251 +++++++++++++++- docs/ceilings.md | 4 +- docs/native-window.md | 6 +- internal/agentplugin/profiles.go | 28 +- internal/agentplugin/profiles_test.go | 3 +- internal/appupdate/appupdate.go | 2 +- internal/appupdate/progress.go | 18 +- internal/appupdate/progress_test.go | 4 +- internal/claudeprofile/login.go | 34 +-- internal/claudeprofile/service.go | 4 +- internal/claudeprofile/service_test.go | 4 +- internal/claudeprofile/update_test.go | 3 +- internal/codexprofile/login.go | 34 +-- internal/codexprofile/service.go | 5 +- internal/codexprofile/service_test.go | 4 +- internal/codexprofile/update_test.go | 3 +- internal/events/send.go | 14 + internal/events/send_test.go | 23 ++ internal/ompprofile/service.go | 5 +- internal/ompprofile/update_test.go | 3 +- internal/profilestore/look.go | 10 +- internal/project/prchanged.go | 14 +- internal/project/prchanged_test.go | 6 +- internal/relay/focus.go | 17 +- internal/relay/inbox.go | 15 +- internal/relay/later.go | 19 +- internal/relay/observe.go | 7 +- internal/relay/relay.go | 37 +-- internal/relay/relay_test.go | 11 +- internal/relay/resume.go | 7 +- internal/relay/workeranswer.go | 9 +- internal/spawn/close.go | 4 +- internal/spawn/close_test.go | 5 +- internal/spawn/folders.go | 34 +-- internal/spawn/folders_test.go | 9 +- internal/spawn/projects.go | 5 +- internal/spawn/projects_test.go | 5 +- internal/spawn/rename.go | 17 +- internal/spawn/spawn.go | 32 +- internal/spawn/spawn_test.go | 9 +- internal/spawn/subagent_test.go | 7 +- internal/store/cost_totals_test.go | 3 +- internal/store/mutations.go | 9 +- internal/store/projectevents.go | 35 +-- internal/store/projectevents_test.go | 10 +- internal/store/store.go | 5 - internal/terminal/cwd.go | 20 +- internal/terminal/duplicate_test.go | 13 +- internal/terminal/io.go | 5 +- internal/terminal/limit.go | 17 +- internal/terminal/limit_service_test.go | 6 +- internal/terminal/pluginversion.go | 12 +- internal/terminal/probehub_test.go | 3 +- internal/terminal/profile.go | 17 +- internal/terminal/profile_unix_test.go | 5 +- internal/terminal/respawn_test.go | 7 +- internal/terminal/sandbox_report_test.go | 5 +- internal/terminal/start.go | 9 +- internal/terminal/terminal.go | 148 ++------- internal/terminal/turnstates.go | 3 +- internal/terminal/turntodo.go | 23 +- internal/terminal/usage.go | 8 +- internal/terminal/usage_test.go | 5 +- internal/themes/selection.go | 7 +- internal/themes/selection_test.go | 6 +- internal/xdgprofile/service.go | 5 +- internal/xdgprofile/update_linux_test.go | 3 +- main.go | 18 +- main_test.go | 5 +- native/backend/api.go | 11 +- native/backend/appupdate.go | 12 +- native/backend/client.go | 13 + native/backend/profile.go | 51 +--- native/backend/pulls.go | 4 - native/backend/themes.go | 4 - native/backend/update.go | 25 +- native/cmd/lich-native/cardstate.go | 54 ++-- native/cmd/lich-native/cardstate_test.go | 19 +- native/cmd/lich-native/dock_review_test.go | 3 +- native/cmd/lich-native/duplicate.go | 12 +- native/cmd/lich-native/duplicate_test.go | 3 +- native/cmd/lich-native/exit_toast_test.go | 3 +- native/cmd/lich-native/footer_layout_test.go | 3 +- native/cmd/lich-native/footer_quota.go | 39 +-- native/cmd/lich-native/footer_readout_test.go | 3 +- native/cmd/lich-native/footer_usage.go | 44 +-- native/cmd/lich-native/gates_click_test.go | 3 +- native/cmd/lich-native/gitstate_test.go | 5 +- native/cmd/lich-native/model.go | 103 +++---- native/cmd/lich-native/model_events.go | 35 +-- native/cmd/lich-native/model_layouts_test.go | 3 +- native/cmd/lich-native/model_test.go | 5 +- native/cmd/lich-native/paste_unfold_test.go | 3 +- native/cmd/lich-native/plugin_gate.go | 3 +- native/cmd/lich-native/projects_test.go | 3 +- .../settings_profiles_look_test.go | 5 +- .../cmd/lich-native/settings_profiles_test.go | 3 +- native/cmd/lich-native/sidebar_blocks_test.go | 3 +- native/cmd/lich-native/status.go | 28 +- native/cmd/lich-native/status_test.go | 7 +- native/cmd/lich-native/stores_events_test.go | 3 +- native/cmd/lich-native/terminal_exit.go | 15 +- native/cmd/lich-native/terminal_exit_test.go | 9 +- native/cmd/lich-native/turnstates_test.go | 11 +- 109 files changed, 1364 insertions(+), 963 deletions(-) create mode 100644 api/eventpayloads.go create mode 100644 api/events.go create mode 100644 internal/events/send.go create mode 100644 internal/events/send_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 5c4861496..36e7973db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,8 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here. - 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. The `/events` payloads are still hand-owned in `native/backend`: touch one's JSON tags - and its mirror there moves in the same change. There is no codegen. + 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 9f140228a..849d750d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,11 +65,10 @@ Then: ```bash for os in linux darwin windows; do GOOS=$os go build ./... && GOOS=$os go vet ./...; done ``` -- **Touched an RPC method's options or result?** Every method 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`. An `/events` - payload is still mirrored by hand in `native/backend`, so the mirror moves in - the same change. There is no codegen. +- **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/api_test.go b/api/api_test.go index ab9bad01f..c5b49f620 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -18,13 +18,20 @@ 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 show up as a diff of testdata/contract.golden. A renamed JSON field +// 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()) + got := render(Methods(), Events()) if *update { if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil { t.Fatal(err) @@ -39,7 +46,7 @@ func TestContractMatchesGolden(t *testing.T) { // back CRLF for the golden committed with LF: the checkout's spelling, not // drift. if got != strings.ReplaceAll(string(want), "\r\n", "\n") { - t.Errorf("the contract moved; if that is meant, run `go test ./api -update` and review the diff.\ngot:\n%s", got) + t.Error("the contract moved; if that is meant, run `go test ./api -update` and review the diff of " + goldenPath) } } @@ -54,6 +61,30 @@ func TestEveryRequestIsOneObject(t *testing.T) { } } +// 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) @@ -72,15 +103,41 @@ func TestDefineRefusesADuplicateName(t *testing.T) { define[None, None]("themes.List") } -// render prints each method as `name(Request) Response`, sorted, followed by -// every named type they reach, each once. -func render(methods map[string]Declared) string { +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:] @@ -105,6 +162,9 @@ func (r *renderer) ref(t reflect.Type) string { } 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()) @@ -118,13 +178,21 @@ func (r *renderer) ref(t reflect.Type) string { func (r *renderer) define(t reflect.Type) string { if t.Implements(marshalerType) { - return fmt.Sprintf("%s = its own MarshalJSON\n", t.Name()) + 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", t.Name()) + fmt.Fprintf(&b, "%s {\n", name) for field := range t.Fields() { name, omitempty, wired := jsonName(field) if !wired { 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/testdata/contract.golden b/api/testdata/contract.golden index f221f14b5..e9f87f8f8 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -198,6 +198,46 @@ 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 } @@ -240,7 +280,7 @@ Profile { personal bool copied,omitempty []string color,omitempty string - image,omitempty []uint8 + image,omitempty base64 } InstallPluginsOptions { @@ -559,7 +599,10 @@ RevertLinesOptions { lines []RevertLine } -RevertResult = its own MarshalJSON +RevertResult (MarshalJSON) { + patch base64 + indexPatch base64 +} SearchOptions { path string @@ -1277,6 +1320,193 @@ TrayLabels { 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 @@ -1421,17 +1651,6 @@ CostTotal { costUsd float64 } -SessionUsage { - id string - percent int - tokens int - window int - model string - effort string - costUsd,omitempty *float64 - costMiss,omitempty string -} - ThemeSource { url string version string @@ -1439,6 +1658,12 @@ ThemeSource { Syntax = map[string]SyntaxStyle +PluginProfileFailure { + profile string + name string + error string +} + PRThreadComment { id int64 author string diff --git a/docs/ceilings.md b/docs/ceilings.md index 54d4b40b2..d71dab17c 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -557,8 +557,8 @@ work when nobody knows it and that the call site never shows. The mechanism and 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. What the - declarations do not cover is the `/events` stream: its payloads are mirrored by hand in `native/backend`. + 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`, diff --git a/docs/native-window.md b/docs/native-window.md index b2880ef18..5236a2e97 100644 --- a/docs/native-window.md +++ b/docs/native-window.md @@ -40,9 +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/`; the RPC's methods and 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. + 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/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 dfaae0c65..89698fb06 100644 --- a/internal/appupdate/appupdate.go +++ b/internal/appupdate/appupdate.go @@ -74,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) 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 65d96d231..d13c3697e 100644 --- a/internal/claudeprofile/service.go +++ b/internal/claudeprofile/service.go @@ -18,6 +18,8 @@ import ( "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 ( @@ -151,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 } 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/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 d7866fe7d..ff92f913f 100644 --- a/internal/codexprofile/service.go +++ b/internal/codexprofile/service.go @@ -13,11 +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/api" + "github.com/lichdotdev/lich/internal/events" ) const ( @@ -155,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 } 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/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 775fbeedc..147b6d276 100644 --- a/internal/ompprofile/service.go +++ b/internal/ompprofile/service.go @@ -12,11 +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/api" + "github.com/lichdotdev/lich/internal/events" ) const ( @@ -134,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/profilestore/look.go b/internal/profilestore/look.go index ec8dec519..3bce75fe0 100644 --- a/internal/profilestore/look.go +++ b/internal/profilestore/look.go @@ -19,15 +19,7 @@ import ( "github.com/lichdotdev/lich/api" ) -// ChangedEvent is the event a profile's update emits once it is written: its -// name, color or image changed. -const ChangedEvent = "profile-changed" - -// 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 type Update = api.ProfileUpdate diff --git a/internal/project/prchanged.go b/internal/project/prchanged.go index 48133059e..51ebee33c 100644 --- a/internal/project/prchanged.go +++ b/internal/project/prchanged.go @@ -1,17 +1,15 @@ package project -import "github.com/lichdotdev/lich/api" +import ( + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) import "time" -// 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" - 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. @@ -20,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/relay/focus.go b/internal/relay/focus.go index 07785bef1..5f375488a 100644 --- a/internal/relay/focus.go +++ b/internal/relay/focus.go @@ -1,16 +1,11 @@ package relay -import "github.com/lichdotdev/lich/api" +import ( + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" +) -// 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" - -// FocusEvent is the payload of FocusEventName. -type FocusEvent struct { - ID string `json:"id"` -} +type FocusEvent = api.SessionFocus type Focused = api.FocusedSession @@ -33,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 6d13a113e..6e196a859 100644 --- a/internal/relay/inbox.go +++ b/internal/relay/inbox.go @@ -9,6 +9,7 @@ import ( "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 @@ -19,17 +20,7 @@ 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 type Collected = api.CollectedResults @@ -280,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/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 525513620..f621203c0 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -143,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 ( @@ -155,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 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/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/spawn/close.go b/internal/spawn/close.go index 6193e56a6..4d016240e 100644 --- a/internal/spawn/close.go +++ b/internal/spawn/close.go @@ -8,6 +8,8 @@ import ( "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 @@ -211,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/folders.go b/internal/spawn/folders.go index 8a8522cb7..16c8ad4f8 100644 --- a/internal/spawn/folders.go +++ b/internal/spawn/folders.go @@ -9,33 +9,13 @@ import ( "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" - -// 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"` -} + "github.com/lichdotdev/lich/internal/events" +) -// ColoredEventName carries sessions painted outside the window, so their cards -// take the colour without a reload. -const ColoredEventName = "sessions-colored" +type FiledEvent = api.SessionsFiled -// 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, @@ -112,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,7 +137,7 @@ 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 } @@ -197,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 21badc715..73aa7764d 100644 --- a/internal/spawn/rename.go +++ b/internal/spawn/rename.go @@ -9,22 +9,7 @@ import ( "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" - -// RenamedEvent is RenamedEventName's payload, the shape terminal's title event -// writes. -type RenamedEvent struct { - ID string `json:"id"` - Label string `json:"label"` -} +type RenamedEvent = api.SessionTitle type Renamed = api.RenamedSession diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go index 9c48b94e6..5219e537e 100644 --- a/internal/spawn/spawn.go +++ b/internal/spawn/spawn.go @@ -35,33 +35,11 @@ import ( "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" -// ClosedEventName carries a session closed outside the window, so the card goes -// away without a reload. Its payload is ClosedEvent. -const ClosedEventName = "session-closed" + "github.com/lichdotdev/lich/internal/events" +) -// 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). @@ -290,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) @@ -345,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/store/cost_totals_test.go b/internal/store/cost_totals_test.go index 4ca0bd58a..116044e74 100644 --- a/internal/store/cost_totals_test.go +++ b/internal/store/cost_totals_test.go @@ -6,11 +6,10 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" "github.com/lichdotdev/lich/internal/rpc" - "github.com/lichdotdev/lich/api" - "context" ) 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 bfc476cd1..f2f61b0ea 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -288,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 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/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/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 bb459c3fd..a1d00497e 100644 --- a/internal/terminal/turnstates.go +++ b/internal/terminal/turnstates.go @@ -6,6 +6,7 @@ import ( "time" "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" ) // statusBook is the last session-status payload published for each session. @@ -98,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 1d8da5ce0..133db3699 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -6,11 +6,9 @@ import ( "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" +) type usageEvent = api.SessionUsage @@ -29,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/selection.go b/internal/themes/selection.go index 64016ab15..fb35be8a5 100644 --- a/internal/themes/selection.go +++ b/internal/themes/selection.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" ) // The settings the windows read the selection from, global ones. A theme @@ -17,10 +18,6 @@ const ( SystemID = "system" ) -// SelectionEventName is pushed on /events with a Selection once a change to -// it is stored. -const SelectionEventName = "theme-selection" - type Selection = api.ThemeSelection // Settings is where the selection is stored: the store's global settings. @@ -76,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/xdgprofile/service.go b/internal/xdgprofile/service.go index 4cdd403db..31cdc723b 100644 --- a/internal/xdgprofile/service.go +++ b/internal/xdgprofile/service.go @@ -10,11 +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/api" + "github.com/lichdotdev/lich/internal/events" ) const ( @@ -98,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 fba91b754..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" @@ -41,8 +42,6 @@ import ( "github.com/lichdotdev/lich/internal/tray" "github.com/lichdotdev/lich/internal/xdgprofile" "github.com/ncruces/zenity" - - "github.com/lichdotdev/lich/api" ) // changelog is embedded so the app can show a "what's new" popup after an @@ -341,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() @@ -577,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. @@ -595,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): @@ -611,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 03dc7244e..264847fed 100644 --- a/main_test.go +++ b/main_test.go @@ -9,6 +9,7 @@ import ( "github.com/coder/websocket" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/events" ) @@ -48,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 index 2d2eb71bb..014d1dfea 100644 --- a/native/backend/api.go +++ b/native/backend/api.go @@ -51,16 +51,7 @@ func (c *Client) Replay(ctx context.Context, id string) ([]byte, error) { 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"` -} +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. diff --git a/native/backend/appupdate.go b/native/backend/appupdate.go index 0c194c0d2..95cbe8ecb 100644 --- a/native/backend/appupdate.go +++ b/native/backend/appupdate.go @@ -8,23 +8,13 @@ import ( "github.com/lichdotdev/lich/api" ) -// AppUpdateProgressEvent is appupdate.ProgressEventName: one step of -// ApplyUpdate, an AppUpdateProgress. -const AppUpdateProgressEvent = "appupdate-progress" - // The phases of AppUpdateProgress. const ( UpdatePhaseDownload = "download" 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 diff --git a/native/backend/client.go b/native/backend/client.go index 93eebb04c..eba730e52 100644 --- a/native/backend/client.go +++ b/native/backend/client.go @@ -177,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/backend/profile.go b/native/backend/profile.go index 870850627..79a56cfce 100644 --- a/native/backend/profile.go +++ b/native/backend/profile.go @@ -8,22 +8,6 @@ import ( "github.com/lichdotdev/lich/api" ) -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. @@ -37,36 +21,11 @@ type ProfilePlugin = api.ProfilePlugin type ProfilePluginResult = api.ProfilePluginResult -// 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"` -} +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 diff --git a/native/backend/pulls.go b/native/backend/pulls.go index 3edb55236..0397321a5 100644 --- a/native/backend/pulls.go +++ b/native/backend/pulls.go @@ -6,10 +6,6 @@ import ( "github.com/lichdotdev/lich/api" ) -// PRChangedEvent is project.PRChangedEvent: a pull request write through lich -// landed on GitHub. -const PRChangedEvent = "pull-request-changed" - type PRChanged = api.PRChanged type ChecksRollup = api.ChecksRollup diff --git a/native/backend/themes.go b/native/backend/themes.go index 9a4a865e9..c3734f987 100644 --- a/native/backend/themes.go +++ b/native/backend/themes.go @@ -17,10 +17,6 @@ type ( ThemeGitResult = api.ThemeGitResult ) -// ThemeSelectionEvent is themes.SelectionEventName: the stored selection -// changed, from this window, another, or `lich theme switch`. -const ThemeSelectionEvent = "theme-selection" - // ThemeSelection reads the stored selection; a theme never chosen reads // "system". func (c *Client) ThemeSelection(ctx context.Context) (ThemeSelection, error) { diff --git a/native/backend/update.go b/native/backend/update.go index 468f6f9b7..182db2206 100644 --- a/native/backend/update.go +++ b/native/backend/update.go @@ -10,30 +10,9 @@ type AppUpdateStatus = api.AppUpdateStatus type PluginStatus = api.PluginStatus -// PluginIncompatibleEvent is raised when a session's hooks report from a -// lich plugin release outside the range this lich speaks. -const PluginIncompatibleEvent = "plugin-incompatible" +type PluginProfiles = api.PluginProfiles -// 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"` -} +type PluginProfileFailure = api.PluginProfileFailure type PatchNotesGroup = api.PatchNotesGroup diff --git a/native/cmd/lich-native/cardstate.go b/native/cmd/lich-native/cardstate.go index fed6a348b..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" ) -// 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 ( @@ -45,16 +28,13 @@ const ( // 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 backend.Event) error { - var s struct { - ID string `json:"id"` - State string `json:"state"` - } - if err := json.Unmarshal(ev.Data, &s); err != nil { + 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 { @@ -102,9 +82,9 @@ func (m *model) statusReport(id, previous string) (statusReport, bool) { // reports whether ev was one of them. 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 backend.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 backend.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 1258f7d1e..e28942263 100644 --- a/native/cmd/lich-native/cardstate_test.go +++ b/native/cmd/lich-native/cardstate_test.go @@ -11,9 +11,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/backend" - "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) func TestCardWearsTheLiveAgentUntilItLeaves(t *testing.T) { @@ -23,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) @@ -140,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/dock_review_test.go b/native/cmd/lich-native/dock_review_test.go index 5f4c1ba75..4d0500c57 100644 --- a/native/cmd/lich-native/dock_review_test.go +++ b/native/cmd/lich-native/dock_review_test.go @@ -11,10 +11,9 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" - - "github.com/lichdotdev/lich/api" ) const twoHunks = `diff --git a/a.go b/a.go 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 0c9ed5621..0105089c6 100644 --- a/native/cmd/lich-native/duplicate_test.go +++ b/native/cmd/lich-native/duplicate_test.go @@ -3,6 +3,7 @@ package main import ( "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -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 2e6f699a6..5b7e39141 100644 --- a/native/cmd/lich-native/exit_toast_test.go +++ b/native/cmd/lich-native/exit_toast_test.go @@ -4,6 +4,7 @@ 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/ui" @@ -163,7 +164,7 @@ func TestRestartStartsTheConversationAsked(t *testing.T) { func TestAReportedConversationReachesTheCard(t *testing.T) { m := newModel(nil, func() {}) m.projects = []backend.Project{{ID: "p", Sessions: []backend.Session{{ID: "s1", Kind: "claude"}}}} - send(m, backend.SessionConversationEvent, backend.ConversationReported{ID: "s1", ProviderSessionID: conversation}) + 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_layout_test.go b/native/cmd/lich-native/footer_layout_test.go index 90962e45e..651bf906c 100644 --- a/native/cmd/lich-native/footer_layout_test.go +++ b/native/cmd/lich-native/footer_layout_test.go @@ -13,9 +13,8 @@ import ( "sync" "testing" - "github.com/lichdotdev/lich/native/backend" - "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // fakeSettings serves store.GetSetting and store.SetSetting from an in-memory diff --git a/native/cmd/lich-native/footer_quota.go b/native/cmd/lich-native/footer_quota.go index daee0ec61..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,6 +10,7 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/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:" @@ -141,22 +138,30 @@ func (q *planQuota) reread(key string) { // 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 backend.Event) { + var id string switch ev.Name { - case sessionLimitEvent, backend.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: diff --git a/native/cmd/lich-native/footer_readout_test.go b/native/cmd/lich-native/footer_readout_test.go index 191cac40b..dc97f5237 100644 --- a/native/cmd/lich-native/footer_readout_test.go +++ b/native/cmd/lich-native/footer_readout_test.go @@ -14,10 +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/api" ) func TestFormatCost(t *testing.T) { diff --git a/native/cmd/lich-native/footer_usage.go b/native/cmd/lich-native/footer_usage.go index 5e10ed8e5..5138ca868 100644 --- a/native/cmd/lich-native/footer_usage.go +++ b/native/cmd/lich-native/footer_usage.go @@ -6,6 +6,7 @@ import ( "slices" "sync" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -68,30 +69,31 @@ func (f *footerUsage) get(id string) (sessionUsage, bool) { // 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 backend.Event) (bool, error) { - var e struct { - ID string `json:"id"` - Agent *string `json:"agent"` - State string `json:"state"` - } - if ev.Name == "session-usage" { + 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/gates_click_test.go b/native/cmd/lich-native/gates_click_test.go index d308239ab..4b04a0092 100644 --- a/native/cmd/lich-native/gates_click_test.go +++ b/native/cmd/lich-native/gates_click_test.go @@ -23,10 +23,9 @@ import ( "gioui.org/widget" "github.com/coder/websocket" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" - - "github.com/lichdotdev/lich/api" ) // gateBackend answers every RPC the startup gates make and reports each one diff --git a/native/cmd/lich-native/gitstate_test.go b/native/cmd/lich-native/gitstate_test.go index 7bd16ace6..31c5a286a 100644 --- a/native/cmd/lich-native/gitstate_test.go +++ b/native/cmd/lich-native/gitstate_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -174,7 +175,7 @@ func TestCardFindsThePullRequestItsTurnOpened(t *testing.T) { forge.mu.Lock() 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/model.go b/native/cmd/lich-native/model.go index 42a553659..b7d851801 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -94,7 +95,7 @@ type model struct { // 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). @@ -110,13 +111,7 @@ 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 *backend.Client, invalidate func()) *model { quota := newPlanQuota(quotaReader(client), invalidate) @@ -269,12 +264,12 @@ func (m *model) hydrateTurnStates(ctx context.Context) error { // serves one client and a newer one replaces it, or lich is gone. 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 @@ -286,113 +281,95 @@ func (m *model) follow(ctx context.Context, events <-chan backend.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 backend.SessionConversationEvent: - var c backend.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 backend.SessionProfileEvent: - var moved backend.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 backend.ProfileChangedEvent: - var c backend.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 backend.ClaudeProfileLoginEvent, backend.CodexProfileLoginEvent: + 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 backend.PluginProfilesEvent: - var pass backend.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 backend.ThemeSelectionEvent: - var sel backend.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 backend.PRChangedEvent: + case api.PullRequestChangedEvent.Name: if m.prChanged != nil { m.prChanged() } - case backend.AppUpdateProgressEvent: - var p backend.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 backend.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 { diff --git a/native/cmd/lich-native/model_events.go b/native/cmd/lich-native/model_events.go index 7cfcd6a10..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/api" "github.com/lichdotdev/lich/native/backend" ) -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" -) - // 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() @@ -88,7 +71,7 @@ func (m *model) sessionFocus(data json.RawMessage) { // window or call made it. 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}) } } @@ -111,7 +94,7 @@ func (m *model) loginStep(ctx context.Context, kind string, step backend.Profile // loginEventKind is the provider a login event name belongs to. func loginEventKind(event string) string { - if event == backend.CodexProfileLoginEvent { + if event == api.CodexProfileLoginEvent.Name { return codexKind } return claudeKind diff --git a/native/cmd/lich-native/model_layouts_test.go b/native/cmd/lich-native/model_layouts_test.go index a504b1c66..6965c34c0 100644 --- a/native/cmd/lich-native/model_layouts_test.go +++ b/native/cmd/lich-native/model_layouts_test.go @@ -12,9 +12,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/backend" - "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) // layoutsBackend answers every call with null and keeps the last value diff --git a/native/cmd/lich-native/model_test.go b/native/cmd/lich-native/model_test.go index b132c89f2..2b5fb36b9 100644 --- a/native/cmd/lich-native/model_test.go +++ b/native/cmd/lich-native/model_test.go @@ -11,9 +11,8 @@ import ( "strconv" "testing" - "github.com/lichdotdev/lich/native/backend" - "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) const ( @@ -365,7 +364,7 @@ func TestASessionMovingProfileMakesItsQuotaReadAgain(t *testing.T) { work.Account = "me@work.example" r.set([]backend.QuotaPlan{work}, nil) events := make(chan backend.Event, 1) - events <- backend.Event{Name: backend.SessionProfileEvent, Data: []byte(`{"id":"s1","profile":"work"}`)} + 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/paste_unfold_test.go b/native/cmd/lich-native/paste_unfold_test.go index 91b467d2e..9554c511b 100644 --- a/native/cmd/lich-native/paste_unfold_test.go +++ b/native/cmd/lich-native/paste_unfold_test.go @@ -9,9 +9,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/gioterm" - "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/gioterm" ) const unfoldRows = 40 diff --git a/native/cmd/lich-native/plugin_gate.go b/native/cmd/lich-native/plugin_gate.go index 7c9fad2eb..7a6c98dfa 100644 --- a/native/cmd/lich-native/plugin_gate.go +++ b/native/cmd/lich-native/plugin_gate.go @@ -11,6 +11,7 @@ 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/ui" @@ -134,7 +135,7 @@ func (u *window) checkPlugin() { a := decidePluginAction(statuses, installDismissed == pluginDismissedFlag, updateDismissed) switch a.kind { case pluginActionIncompatible: - t := ui.Toast{ID: backend.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) }} diff --git a/native/cmd/lich-native/projects_test.go b/native/cmd/lich-native/projects_test.go index c3c384889..13a5dd422 100644 --- a/native/cmd/lich-native/projects_test.go +++ b/native/cmd/lich-native/projects_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -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(backend.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) } } diff --git a/native/cmd/lich-native/settings_profiles_look_test.go b/native/cmd/lich-native/settings_profiles_look_test.go index ee98c84fa..88950495c 100644 --- a/native/cmd/lich-native/settings_profiles_look_test.go +++ b/native/cmd/lich-native/settings_profiles_look_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -50,7 +51,7 @@ func TestAProfileChangedEventRedrawsTheProfileKeepingItsLogin(t *testing.T) { m := newModel(nil, func() {}) 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(backend.ProfileChanged{Provider: claudeKind, Profile: backend.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,7 +59,7 @@ 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(backend.ProfileChanged{Provider: "gemini", Profile: backend.Profile{Slug: "work"}}) + m.profileChanged(backend.ProfileChanged{Provider: "gemini", Profile: api.Profile{Slug: "work"}}) } func TestTheProfileMenuOffersRemoveImageOnlyWithAnImage(t *testing.T) { diff --git a/native/cmd/lich-native/settings_profiles_test.go b/native/cmd/lich-native/settings_profiles_test.go index 97533fb5a..474b78c74 100644 --- a/native/cmd/lich-native/settings_profiles_test.go +++ b/native/cmd/lich-native/settings_profiles_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -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(backend.CodexProfileLoginEvent) != codexKind || loginEventKind(backend.ClaudeProfileLoginEvent) != claudeKind { + if loginEventKind(api.CodexProfileLoginEvent.Name) != codexKind || loginEventKind(api.ClaudeProfileLoginEvent.Name) != claudeKind { t.Error("a login event was routed to the wrong provider") } } diff --git a/native/cmd/lich-native/sidebar_blocks_test.go b/native/cmd/lich-native/sidebar_blocks_test.go index 1de1b99b6..13f068c04 100644 --- a/native/cmd/lich-native/sidebar_blocks_test.go +++ b/native/cmd/lich-native/sidebar_blocks_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -23,7 +24,7 @@ func sidebarModel(t *testing.T, stored []backend.Session) (*model, *sidebarBacke } 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 { diff --git a/native/cmd/lich-native/status.go b/native/cmd/lich-native/status.go index 3b26a7617..af6c268c0 100644 --- a/native/cmd/lich-native/status.go +++ b/native/cmd/lich-native/status.go @@ -1,11 +1,10 @@ package main import ( - "encoding/json" "errors" - "fmt" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -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 { @@ -99,15 +85,15 @@ func (s *statusStore) entryOf(id string) *statusEntry { // 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 backend.Event, now time.Time) (changed bool, err error) { - if ev.Name != statusEventName { + 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 diff --git a/native/cmd/lich-native/status_test.go b/native/cmd/lich-native/status_test.go index d3837bda0..2b7c87b9e 100644 --- a/native/cmd/lich-native/status_test.go +++ b/native/cmd/lich-native/status_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" ) @@ -17,7 +18,7 @@ func statusEv(t *testing.T, p statusEvent) backend.Event { if err != nil { t.Fatal(err) } - return backend.Event{Name: statusEventName, Data: data} + return backend.Event{Name: api.SessionStatusEvent.Name, Data: data} } // step is one report, landing secs seconds after t0. @@ -212,10 +213,10 @@ func TestStatusStoreApplyErrors(t *testing.T) { 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(backend.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(backend.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 531acb0ca..2b10f0d71 100644 --- a/native/cmd/lich-native/stores_events_test.go +++ b/native/cmd/lich-native/stores_events_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/ui" ) @@ -295,7 +296,7 @@ func TestALoginEndingReloadsTheProfilesBeforeTheScreenHearsIt(t *testing.T) { } heard <- slugs } - send(m, backend.ClaudeProfileLoginEvent, backend.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) + send(m, api.ClaudeProfileLoginEvent.Name, backend.ProfileLogin{Profile: "work", Done: true, LoggedIn: true}) select { case slugs := <-heard: diff --git a/native/cmd/lich-native/terminal_exit.go b/native/cmd/lich-native/terminal_exit.go index 7f54f161e..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/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 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/turnstates_test.go b/native/cmd/lich-native/turnstates_test.go index ff58e962e..56aa6d8bf 100644 --- a/native/cmd/lich-native/turnstates_test.go +++ b/native/cmd/lich-native/turnstates_test.go @@ -10,9 +10,8 @@ import ( "testing" "time" - "github.com/lichdotdev/lich/native/backend" - "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" ) func turnState(id, state string) backend.TurnState { @@ -181,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() @@ -201,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) } @@ -217,8 +216,8 @@ func TestExitEndsTheTurnTheProcessDiedIn(t *testing.T) { func TestExitKeepsAFinishedTurnUnreadAndOtherSessionsBusy(t *testing.T) { m := newTestModel(nil) m.projects[0].Sessions = append(m.projects[0].Sessions, backend.Session{ID: "s2"}) - send(m, statusEventName, statusEvent{ID: "s1", State: "done"}) - send(m, statusEventName, statusEvent{ID: "s2", State: "busy"}) + 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{}) From 96b14d27fe1e83e49f44e4196be8dcfebda988df Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 08:10:11 -0300 Subject: [PATCH 13/13] ci: check the api contract against its golden as its own step --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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