diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ba7c3c..8be2d464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Fork a session in the native window.** Right-click a card and pick + Fork › Here to carry its conversation on in a new card right under it, in + the same checkout, or Fork › Worktree… to take it to a new worktree along + with your uncommitted changes. The copy is named after the original + ("auth-refactor 2") and wears a fork mark. Claude Code, Codex and opencode + only: the other providers only resume. - **The native window warns when two cards run one conversation.** Resuming, from inside an agent's own TUI, a conversation another card already has open raises a warning in the bottom-right corner; Switch closes the card diff --git a/README.es.md b/README.es.md index 798b0e0e..d0b521c7 100644 --- a/README.es.md +++ b/README.es.md @@ -128,7 +128,7 @@ porque lee lo que cada CLI deja escrito y no hay dos que anoten las mismas cosas | Buscar en la conversación desde la paleta | sí | sí | sí | sí | sí | sí | no | sí | | Reanudar una conversación iniciada fuera de lich, desde la pestaña Historial de la paleta | sí | sí | sí | sí | sí | Linux; macOS y Windows sin probar | sí | sí | | Un pegado largo puede llegar como texto editable en vez de un marcador plegado (Ajustes › Proveedores, desactivado por defecto) | sí | no | no | sí | no medido | no, se convierte en un adjunto `paste_N.txt` | no | sí | -| Bifurcar una conversación en un nuevo worktree | sí | sí | no | sí | no | no | no | no | +| Bifurcar una conversación en un nuevo worktree, o en su propio checkout en la ventana nativa | sí | sí | no | sí | no | no | no | no | | Aviso en la ventana nativa cuando retomas, desde la propia app del agente, una conversación que otra tarjeta tiene abierta | sí | en tu próximo prompt | en tu próximo prompt, sin probar | no | no | en su próxima llamada a una tool | no | sí | | Abrir una sesión con un esfuerzo de razonamiento elegido | sí | sí | sí | no | sí | no | en el nombre del modelo | sí | | Abrir una sesión con ultracode activado | sí | no | no | no | no | no | no | no | diff --git a/README.ja.md b/README.ja.md index f4f33963..f2347a0d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -119,7 +119,7 @@ lich は各 CLI が書き残したものを読むので、書き残す内容は | パレットから会話を検索 | あり | あり | あり | あり | あり | あり | なし | あり | | lich の外で始めた会話を、パレットの History タブから再開 | あり | あり | あり | あり | あり | Linux のみ。macOS と Windows は未検証 | あり | あり | | 長いペーストを、折りたたまれたプレースホルダーではなく編集可能なテキストとして貼り付けられる(Settings › Providers、既定ではオフ) | あり | なし | なし | あり | 未計測 | なし(`paste_N.txt` の添付になる) | なし | あり | -| 会話を新しいワークツリーにフォーク | あり | あり | なし | あり | なし | なし | なし | なし | +| 会話を新しいワークツリーにフォーク(ネイティブウィンドウでは同じチェックアウト内にも) | あり | あり | なし | あり | なし | なし | なし | なし | | 別のカードで開いている会話をエージェント自身のアプリで再開すると、ネイティブウィンドウに警告 | あり | 次のプロンプトで | 次のプロンプトで(未検証) | なし | なし | 次のツール呼び出しで | なし | あり | | 推論エフォートを指定してセッションを開く | あり | あり | あり | なし | あり | なし | モデル名で指定 | あり | | ultracode をオンにしてセッションを開く | あり | なし | なし | なし | なし | なし | なし | なし | diff --git a/README.ko.md b/README.ko.md index e92c9aef..8348242f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -116,7 +116,7 @@ lich로 할 수 있는 일: | 팔레트에서 대화 검색 | 예 | 예 | 예 | 예 | 예 | 예 | 아니요 | 예 | | lich 밖에서 시작한 대화를 팔레트의 History 탭에서 재개 | 예 | 예 | 예 | 예 | 예 | Linux; macOS와 Windows는 미검증 | 예 | 예 | | 긴 붙여 넣기를 접힌 자리 표시자 대신 편집 가능한 텍스트로 넣기(Settings › Providers, 기본값 꺼짐) | 예 | 아니요 | 아니요 | 예 | 측정 안 됨 | 아니요, `paste_N.txt` 첨부 파일이 됨 | 아니요 | 예 | -| 대화를 새 worktree로 포크 | 예 | 예 | 아니요 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | +| 대화를 새 worktree로 포크, 네이티브 창에서는 같은 체크아웃에도 포크 | 예 | 예 | 아니요 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | | 다른 카드에 열려 있는 대화를 에이전트 자체 앱에서 재개하면 네이티브 창에 경고 | 예 | 다음 프롬프트에서 | 다음 프롬프트에서, 미검증 | 아니요 | 아니요 | 다음 도구 호출에서 | 아니요 | 예 | | 원하는 reasoning effort로 세션 열기 | 예 | 예 | 예 | 아니요 | 예 | 아니요 | 모델 이름으로 | 예 | | ultracode를 켠 채로 세션 열기 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | diff --git a/README.md b/README.md index 999206d7..be1132e7 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ reads what each CLI writes down and no two of them write down the same things. | Search the conversation from the palette | yes | yes | yes | yes | yes | yes | no | yes | | Resume a conversation started outside lich, from the palette's History tab | yes | yes | yes | yes | yes | Linux; macOS and Windows untested | yes | yes | | A long paste can land as editable text instead of a folded placeholder (Settings › Providers, off by default) | yes | no | no | yes | not measured | no, it becomes a `paste_N.txt` attachment | no | yes | -| Fork a conversation into a new worktree | yes | yes | no | yes | no | no | no | no | +| Fork a conversation into a new worktree, or in its own checkout in the native window | yes | yes | no | yes | no | no | no | no | | Warned in the native window when you resume, from the agent's own app, a conversation another card has open | yes | on your next prompt | on your next prompt, untested | no | no | at its next tool call | no | yes | | Open a session at a chosen reasoning effort | yes | yes | yes | no | yes | no | in the model name | yes | | Open a session with ultracode on | yes | no | no | no | no | no | no | no | diff --git a/README.pt-BR.md b/README.pt-BR.md index 70df87de..78549874 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -131,7 +131,7 @@ mesmas coisas. | Pesquisar a conversa pela paleta | sim | sim | sim | sim | sim | sim | não | sim | | Retomar pela aba Histórico da paleta uma conversa iniciada fora do lich | sim | sim | sim | sim | sim | Linux; macOS e Windows não testados | sim | sim | | Um paste longo pode chegar como texto editável em vez de um marcador dobrado (Configurações › Provedores, desligado por padrão) | sim | não | não | sim | não medido | não, vira um anexo `paste_N.txt` | não | sim | -| Fazer fork de uma conversa para uma nova worktree | sim | sim | não | sim | não | não | não | não | +| Fazer fork de uma conversa para uma nova worktree, ou no mesmo checkout na janela nativa | sim | sim | não | sim | não | não | não | não | | Aviso na janela nativa quando você retoma, pelo próprio app do agente, uma conversa que outro card tem aberta | sim | no seu próximo prompt | no seu próximo prompt, não testado | não | não | na próxima chamada de tool | não | sim | | Abrir uma sessão com um esforço de raciocínio escolhido | sim | sim | sim | não | sim | não | no nome do modelo | sim | | Abrir uma sessão com o ultracode ligado | sim | não | não | não | não | não | não | não | diff --git a/README.ru.md b/README.ru.md index 1aeedbb9..9603a7f3 100644 --- a/README.ru.md +++ b/README.ru.md @@ -132,7 +132,7 @@ lich вообще не запускается. | Поиск по разговору из палитры | да | да | да | да | да | да | нет | да | | Возобновление разговора, начатого вне lich, со вкладки «История» палитры | да | да | да | да | да | Linux; macOS и Windows не проверялись | да | да | | Длинная вставка может попасть как редактируемый текст вместо свёрнутой заглушки (Настройки › Провайдеры, по умолчанию выключено) | да | нет | нет | да | не измерялось | нет, она становится вложением `paste_N.txt` | нет | да | -| Форк разговора в новый worktree | да | да | нет | да | нет | нет | нет | нет | +| Форк разговора в новый worktree, а в нативном окне и в том же checkout | да | да | нет | да | нет | нет | нет | нет | | Предупреждение в нативном окне, когда вы возобновляете в самом приложении агента разговор, открытый в другой карточке | да | при следующем промпте | при следующем промпте, не проверено | нет | нет | при следующем вызове инструмента | нет | да | | Открытие сессии с выбранным уровнем рассуждений | да | да | да | нет | да | нет | в названии модели | да | | Открытие сессии с включённым ultracode | да | нет | нет | нет | нет | нет | нет | нет | diff --git a/README.zh-CN.md b/README.zh-CN.md index be880904..8ad237ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,7 +108,7 @@ | 从命令面板搜索对话里说过的内容 | 有 | 有 | 有 | 有 | 有 | 有 | 无 | 有 | | 在命令面板的 History 页恢复在 lich 之外开始的对话 | 有 | 有 | 有 | 有 | 有 | Linux;macOS 和 Windows 未测 | 有 | 有 | | 长文本粘贴可以以可编辑的完整文本出现,而不是折叠成占位符(设置 › Providers,默认关闭) | 有 | 无 | 无 | 有 | 未测 | 无,会变成 `paste_N.txt` 附件 | 无 | 有 | -| 把一段对话分叉到新的 worktree | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | +| 把一段对话分叉到新的 worktree,在原生窗口中也可在同一检出内分叉 | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 | | 在智能体自己的应用里恢复另一张卡片已打开的对话时,原生窗口发出警告 | 有 | 在你的下一个提示时 | 在你的下一个提示时,未测试 | 无 | 无 | 在它的下一次工具调用时 | 无 | 有 | | 按指定的推理强度打开会话 | 有 | 有 | 有 | 无 | 有 | 无 | 写在模型名里 | 有 | | 以 ultracode 模式打开会话 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 | diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 3e4c8367..5b85b944 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -414,6 +414,9 @@ export interface StoredSession { originSessionId: string /** What that session was called at the time; all that survives its close. */ originLabel: string + /** Whether the session was born branching its origin's conversation, rather + * than delegated or opened as a subagent. Only the native window reads it. */ + forked?: boolean /** When the prompt below is due, in unix seconds; 0 for a session with * nothing waiting. One slot per session — scheduling again replaces it. */ scheduledAt: number diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 5a9fb99b..118afd95 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -22,6 +22,7 @@ var migrations = []func(*sql.Tx) error{ addSessionColor, addProviderSessionTombstones, addSessionProfile, + addSessionForked, } // addSessionEffort is version 2: the reasoning effort a session was opened at, @@ -123,6 +124,14 @@ func addSessionProfile(tx *sql.Tx) error { return err } +// addSessionForked is version 10: whether the session was born branching +// another's conversation (MarkForked). The origin columns cannot say it: a +// delegate and a subagent card carry an origin too. +func addSessionForked(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE sessions ADD COLUMN forked INTEGER NOT NULL DEFAULT 0`) + return err +} + // NewerSchemaError is an open refused because the database was written by a // newer lich than this one. Opening it anyway would let this binary write its // defaults over columns it does not know about, so it is not opened at all. diff --git a/internal/store/mutations.go b/internal/store/mutations.go index 4d9a24f4..2268073e 100644 --- a/internal/store/mutations.go +++ b/internal/store/mutations.go @@ -364,14 +364,14 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err row := tx.QueryRow( `SELECT id, project_id, label, kind, path, provider_session_id, label_auto, model, effort, ultracode, subagent, entrypoint, sandbox, pinned, folder, color, origin_session_id, origin_label, - scheduled_at, scheduled_prompt, fork_cost_offset, profile + forked, scheduled_at, scheduled_prompt, fork_cost_offset, profile FROM sessions `+where, args..., ) if err := row.Scan( &old.ID, &projectID, &old.Label, &old.Kind, &old.Path, &old.ProviderSessionID, &labelAuto, &model, &effort, &ultracode, &subagent, &entrypoint, &sandbox, &old.Pinned, &folder, &color, - &old.OriginSessionID, &old.OriginLabel, + &old.OriginSessionID, &old.OriginLabel, &old.Forked, &old.ScheduledAt, &old.ScheduledPrompt, &forkOffset, &profile, ); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -422,11 +422,11 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err `INSERT INTO sessions (id, project_id, label, kind, path, provider_session_id, label_auto, model, effort, ultracode, subagent, entrypoint, sandbox, pinned, folder, color, origin_session_id, origin_label, - scheduled_at, scheduled_prompt, fork_cost_offset, profile, position) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, `+nextSessionPosition+`)`, + forked, scheduled_at, scheduled_prompt, fork_cost_offset, profile, position) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, `+nextSessionPosition+`)`, newSessionID, projectID, old.Label, old.Kind, old.Path, old.ProviderSessionID, labelAuto, model, effort, ultracode, subagent, entrypoint, sandbox, old.Pinned, folder, color, old.OriginSessionID, old.OriginLabel, - old.ScheduledAt, old.ScheduledPrompt, forkOffset, profile, projectID, + old.Forked, old.ScheduledAt, old.ScheduledPrompt, forkOffset, profile, projectID, ); err != nil { return fmt.Errorf("reinsert session %q: %w", newSessionID, err) } @@ -453,6 +453,7 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err Profile: profile, OriginSessionID: old.OriginSessionID, OriginLabel: old.OriginLabel, + Forked: old.Forked, ScheduledAt: old.ScheduledAt, ScheduledPrompt: old.ScheduledPrompt, } @@ -821,6 +822,15 @@ func (s *Service) InheritSubagent(sessionID, forkedFrom string) error { return nil } +// MarkForked records that sessionID was born branching its origin's +// conversation, written by the window that opens the fork along with the row. +func (s *Service) MarkForked(sessionID string) error { + if _, err := s.db.Exec(`UPDATE sessions SET forked = 1 WHERE id = ?`, sessionID); err != nil { + return fmt.Errorf("mark %q forked: %w", sessionID, err) + } + return nil +} + // InheritUltracode turns ultracode on for a fork when the session that ran the // conversation it branches, forkedFrom, has it on. The fork's row is a fresh // insert that knows nothing of its parent, and Claude Code drops ultracode on diff --git a/internal/store/origin_test.go b/internal/store/origin_test.go index eb7d0c66..8d7c34d2 100644 --- a/internal/store/origin_test.go +++ b/internal/store/origin_test.go @@ -97,3 +97,37 @@ func TestReopenWorktreeSessionCarriesTheOrigin(t *testing.T) { t.Errorf("reloaded origin = %+v, want the parent it was opened from", reloaded) } } + +// TestMarkForkedRidesTheRowThroughAReopen: a fork is told apart from a +// delegate or a subagent, which carry an origin too, by its own mark, and a +// park and resume is not what makes a card stop being a fork. +func TestMarkForkedRidesTheRowThroughAReopen(t *testing.T) { + svc := newTestStore(t) + _ = svc.AddProject("p1", "alpha", "/tmp/alpha") + _ = svc.AddSession("p1", "keep", "auth-refactor", "claude", "", 1, "") + _ = svc.AddSessionFrom("p1", "fork", "auth-refactor 2", "claude", "", 2, "keep", "auth-refactor", "") + _ = svc.AddSessionFrom("p1", "worker", "worker", "claude", "", 3, "keep", "auth-refactor", "") + if err := svc.MarkForked("fork"); err != nil { + t.Fatalf("MarkForked: %v", err) + } + + forked := func() map[string]bool { + projects, _ := svc.LoadState() + got := map[string]bool{} + for _, s := range projects[0].Sessions { + got[s.ID] = s.Forked + } + return got + } + if got := forked(); !got["fork"] || got["worker"] || got["keep"] { + t.Fatalf("forked = %v, want only the fork marked", got) + } + _ = svc.CloseSession("p1", "fork", "keep") + restored, err := svc.ReopenSession("fork", "fork-again") + if err != nil || restored == nil || !restored.Forked { + t.Fatalf("reopen = %+v, %v; want the mark carried", restored, err) + } + if !forked()["fork-again"] { + t.Error("the reopened row lost its fork mark on reload") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index ea7a7352..8d8e7caf 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -400,6 +400,9 @@ type Session struct { Profile string `json:"profile"` 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 @@ -690,7 +693,7 @@ func (s *Service) ProjectAt(path string) (string, string) { func (s *Service) sessionsOf(projectID string) ([]Session, error) { rows, err := s.db.Query( `SELECT id, label, kind, path, provider_session_id, entrypoint, sandbox, pinned, - folder, color, profile, origin_session_id, origin_label, scheduled_at, scheduled_prompt, unread, + folder, color, profile, origin_session_id, origin_label, forked, scheduled_at, scheduled_prompt, unread, mcp_servers, sandbox_links, EXISTS (SELECT 1 FROM session_last_turn WHERE session_id = sessions.id) FROM sessions WHERE project_id = ? AND is_open = 1 ORDER BY position, rowid`, @@ -708,7 +711,7 @@ func (s *Service) sessionsOf(projectID string) ([]Session, error) { if err := rows.Scan( &sess.ID, &sess.Label, &sess.Kind, &sess.Path, &sess.ProviderSessionID, &sess.Entrypoint, &sess.Sandbox, &sess.Pinned, &sess.Folder, &sess.Color, &sess.Profile, - &sess.OriginSessionID, &sess.OriginLabel, + &sess.OriginSessionID, &sess.OriginLabel, &sess.Forked, &sess.ScheduledAt, &sess.ScheduledPrompt, &sess.Unread, &servers, &links, &sess.HasLastTurn, ); err != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 79b3bde0..6cde1413 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -969,7 +969,7 @@ func TestOpenDropsTheTerminalThemeSetting(t *testing.T) { _ = before.SetSetting("appearance.terminalTheme", "p1", "emerald") _ = before.SetSetting("appearance.theme", "", "rose-pine") _ = before.Close() - seedDB(t, path, `ALTER TABLE sessions DROP COLUMN effort; ALTER TABLE sessions DROP COLUMN ultracode; ALTER TABLE sessions DROP COLUMN subagent; ALTER TABLE session_costs DROP COLUMN reported; ALTER TABLE sessions DROP COLUMN color; ALTER TABLE sessions DROP COLUMN profile; DROP TABLE provider_session_tombstones; DROP TRIGGER sessions_tombstone_ad; DROP TRIGGER sessions_tombstone_ai; DROP TRIGGER sessions_tombstone_au; PRAGMA user_version = 0`) + seedDB(t, path, `ALTER TABLE sessions DROP COLUMN effort; ALTER TABLE sessions DROP COLUMN ultracode; ALTER TABLE sessions DROP COLUMN subagent; ALTER TABLE session_costs DROP COLUMN reported; ALTER TABLE sessions DROP COLUMN color; ALTER TABLE sessions DROP COLUMN profile; ALTER TABLE sessions DROP COLUMN forked; DROP TABLE provider_session_tombstones; DROP TRIGGER sessions_tombstone_ad; DROP TRIGGER sessions_tombstone_ai; DROP TRIGGER sessions_tombstone_au; PRAGMA user_version = 0`) after, err := open(path) if err != nil { diff --git a/native/cmd/lich-native/cardmenu.go b/native/cmd/lich-native/cardmenu.go index 0d6b13ed..50ade603 100644 --- a/native/cmd/lich-native/cardmenu.go +++ b/native/cmd/lich-native/cardmenu.go @@ -1,6 +1,8 @@ package main import ( + "slices" + "github.com/omartelo/lich/native/i18n" "github.com/omartelo/lich/native/ui" "github.com/omartelo/lich/native/ui/icons" @@ -18,6 +20,7 @@ var ( icMenuClose = icons.Lucide("x") icTakeOut = icons.Lucide("folder-minus") icNewFolder = icons.Lucide("plus") + icMenuFork = icons.Lucide("git-fork") ) // cardMenu is the session card's right-click menu, cut to what this window @@ -41,6 +44,9 @@ func cardMenu(sv *sessionView, folders []string, act *sidebarAction, startRename {Kind: ui.MenuSubTrigger, Label: i18n.T("sidebar.sessionCard.openIn"), Icon: icOpenIn, Sub: openInMenu(sv, act)}, pullRequestItem(sv, act), } + if fork, ok := forkItem(sv, act); ok { + entries = append(entries, fork) + } entries = append(entries, extra...) if !sv.Pinned { entries = append(entries, @@ -118,6 +124,28 @@ func openInMenu(sv *sessionView, act *sidebarAction) []ui.MenuEntry { // pullRequestItem opens the branch's PR on the Pulls screen, the session // brought up with it; disabled while the branch has none. +// forkItem is Fork ▸ Here / Worktree… on a session whose provider branches +// conversations and that has one to branch. A provider that only resumes +// keeps the row, dead and saying why, so a missing Fork never reads as an +// oversight; a shell has nothing to fork and gets no row. +func forkItem(sv *sessionView, act *sidebarAction) (ui.MenuEntry, bool) { + label := i18n.T("sidebar.sessionCard.fork") + if !canFork(sv.Kind) { + if !slices.Contains(resumableKinds, sv.Kind) { + return ui.MenuEntry{}, false + } + return ui.MenuEntry{Label: label, Icon: icMenuFork, Disabled: true, Shortcut: i18n.T("sidebar.sessionCard.forkResumeOnly")}, true + } + if sv.ProviderSessionID == "" { + return ui.MenuEntry{}, false + } + id := sv.ID + return ui.MenuEntry{Kind: ui.MenuSubTrigger, Label: label, Icon: icMenuFork, Sub: []ui.MenuEntry{ + {Label: i18n.T("sidebar.sessionCard.forkHere"), Icon: icFolder, OnClick: func() { act.forkHere = id }}, + {Label: i18n.T("sidebar.sessionCard.forkWorktree"), Icon: icBranch, OnClick: func() { act.forkWorktree = id }}, + }}, true +} + func pullRequestItem(sv *sessionView, act *sidebarAction) ui.MenuEntry { item := ui.MenuEntry{Label: i18n.T("sidebar.sessionCard.pullRequest"), Icon: icMenuPR, Disabled: sv.pr == nil} if sv.pr != nil { diff --git a/native/cmd/lich-native/cardmenu_test.go b/native/cmd/lich-native/cardmenu_test.go index 3306ed2b..f42835fe 100644 --- a/native/cmd/lich-native/cardmenu_test.go +++ b/native/cmd/lich-native/cardmenu_test.go @@ -86,3 +86,33 @@ func TestCardMenuFilesIntoAnotherFolderOrOutOfItsOwn(t *testing.T) { t.Errorf("unfiled card with no folders: %v, want %v", labels(sub), want) } } + +func TestCardMenuForkEntry(t *testing.T) { + var act sidebarAction + sv := &sessionView{Session: lichclient.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)) + } + entry(t, fork.Sub, "Here").OnClick() + entry(t, fork.Sub, "Worktree…").OnClick() + if act.forkHere != "s1" || act.forkWorktree != "s1" { + t.Fatalf("act %+v, want both forks asked for s1", act) + } + + sv.Kind = "crush" + dead := entry(t, cardMenu(sv, nil, &act, func() {}, func() {}), "Fork") + if !dead.Disabled || dead.Sub != nil || dead.Shortcut == "" { + t.Errorf("crush fork entry %+v, want it disabled, saying why, with no submenu", dead) + } + + for _, s := range []lichclient.Session{ + {ID: "s1", Kind: "claude"}, // no conversation to branch yet + {ID: "s1", Kind: "shell", ProviderSessionID: "conv-1"}, // nothing a shell could fork + } { + sv.Session = s + if slices.Contains(labels(cardMenu(sv, nil, &act, func() {}, func() {})), "Fork") { + t.Errorf("%s session with conversation %q offers Fork", s.Kind, s.ProviderSessionID) + } + } +} diff --git a/native/cmd/lich-native/fork.go b/native/cmd/lich-native/fork.go new file mode 100644 index 00000000..eb369989 --- /dev/null +++ b/native/cmd/lich-native/fork.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "slices" + "strconv" + "strings" + + "github.com/omartelo/lich/native/lichclient" +) + +// forkableKinds are providers.SupportsFork: the providers whose own CLI +// branches a conversation (`claude --fork-session`, `codex fork`, +// `opencode --fork`). The other five only resume, and lich forges no fork for +// them (docs/ceilings.md). +var forkableKinds = []string{"claude", "codex", "opencode"} + +func canFork(kind string) bool { return slices.Contains(forkableKinds, kind) } + +// forkLabel names a fork of the session called parent, among the labels +// already taken in its project: the parent's name without its trailing +// number, then the first number above the parent's own that no card holds. +// "auth-refactor" forks into "auth-refactor 2", and "auth-refactor 2" into +// "auth-refactor 3" unless a 3 is already there. +func forkLabel(parent string, taken []string) string { + base, n := splitNumber(parent) + for { + n++ + label := fmt.Sprintf("%s %d", base, n) + if !slices.Contains(taken, label) { + return label + } + } +} + +// splitNumber is label without a trailing " N", and N, 1 for a label with +// none: the bare name is the first of its line. +func splitNumber(label string) (string, int) { + i := strings.LastIndexByte(label, ' ') + if i < 0 { + return label, 1 + } + n, err := strconv.Atoi(label[i+1:]) + if err != nil || n < 1 { + return label, 1 + } + return label[:i], n +} + +// forkSource is the session a fork branches: what the child is filed and +// named after, and the conversation its first start copies. +type forkSource struct { + id, label, kind, conversation, folder string + // path is the session's checkout as stored, "" for the project's own. + path string +} + +func forkSourceOf(s lichclient.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 { + labels := make([]string, len(p.Sessions)) + for i, s := range p.Sessions { + labels[i] = s.Label + } + return labels +} + +// placedAfter is ids with id moved to sit right after parent, so a fork +// lands under the card it came from rather than at the end; ids unchanged +// when parent is not among them. +func placedAfter(ids []string, id, parent string) []string { + out := slices.DeleteFunc(slices.Clone(ids), func(x string) bool { return x == id }) + at := slices.Index(out, parent) + if at < 0 { + return append(out, id) + } + return slices.Insert(out, at+1, id) +} diff --git a/native/cmd/lich-native/fork_actions.go b/native/cmd/lich-native/fork_actions.go new file mode 100644 index 00000000..7b629034 --- /dev/null +++ b/native/cmd/lich-native/fork_actions.go @@ -0,0 +1,96 @@ +package main + +import ( + "crypto/rand" + "strings" + + "github.com/omartelo/lich/native/i18n" + "github.com/omartelo/lich/native/lichclient" + "github.com/omartelo/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) { + m.mu.Lock() + defer m.mu.Unlock() + p, i := m.sessionAt(id) + if p == nil { + return lichclient.Project{}, forkSource{}, false + } + return *p, forkSourceOf(p.Sessions[i]), true +} + +// forkHere is Fork ▸ Here: a new card in the session's own checkout, under +// it, branching its conversation. Run off the frame. +func (u *window) forkHere(id string) { + p, src, ok := u.model.forkSource(id) + if !ok { + return + } + if err := u.conversationLeft(p, src); err != nil { + u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: err.Error()}) + 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)})}) + } +} + +// forkPruned is a fork refused because the provider no longer has the +// conversation; its text is the sentence the window shows. +type forkPruned struct{ text string } + +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 { + cwd := src.path + if cwd == "" { + cwd = p.Path + } + ok, err := u.client.ResumeAvailable(u.ctx, src.kind, src.conversation, cwd) + if err != nil { + return err + } + if !ok { + return forkPruned{i18n.T("sidebar.fork.pruned", i18n.Args{"name": src.label, "provider": src.kind})} + } + return nil +} + +// 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) { + 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 { + return "", err + } + if err := u.client.MarkForked(u.ctx, id); err != nil { + return "", err + } + u.terms.queueFork(id, src.conversation) + u.logErr("reorder sessions", u.client.ReorderSessions(u.ctx, p.ID, placedAfter(sessionIDs(p), id, src.id))) + return id, u.showNewSession(p.ID, id) +} + +// showForkDialog is Fork ▸ Worktree…: the worktree dialog in its fork +// mode, on the session's own checkout and branch. +func (u *window) showForkDialog(id string) { + p, src, ok := u.model.forkSource(id) + if !ok { + return + } + u.settings.hide() + dir := src.path + if dir == "" { + dir = p.Path + } + checkout, _ := u.model.gitStatus(dir) + u.worktree.show(u.ctx, p, src.kind, dir, checkout, &src) + go u.model.refreshPath(u.ctx, dir) +} diff --git a/native/cmd/lich-native/fork_test.go b/native/cmd/lich-native/fork_test.go new file mode 100644 index 00000000..5cbdf081 --- /dev/null +++ b/native/cmd/lich-native/fork_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "slices" + "testing" +) + +func TestForkLabelTakesTheNextFreeNumberOfItsBase(t *testing.T) { + cases := []struct { + parent string + taken []string + want string + }{ + {"auth-refactor", []string{"auth-refactor"}, "auth-refactor 2"}, + {"auth-refactor 2", []string{"auth-refactor", "auth-refactor 2"}, "auth-refactor 3"}, + {"auth-refactor", []string{"auth-refactor", "auth-refactor 2", "auth-refactor 3"}, "auth-refactor 4"}, + {"auth-refactor 2", []string{"auth-refactor 2", "auth-refactor 4"}, "auth-refactor 3"}, + {"refresh queue", []string{"refresh queue", "auth-refactor 2"}, "refresh queue 2"}, + {"Claude 3", []string{"Claude 3"}, "Claude 4"}, + {"v2", []string{"v2"}, "v2 2"}, + } + for _, c := range cases { + if got := forkLabel(c.parent, c.taken); got != c.want { + t.Errorf("forkLabel(%q, %v) = %q, want %q", c.parent, c.taken, got, c.want) + } + } +} + +func TestOnlyTheThreeProvidersThatBranchAConversationFork(t *testing.T) { + for kind, want := range map[string]bool{ + "claude": true, "codex": true, "opencode": true, + "omp": false, "crush": false, "cursor": false, "kiro": false, "antigravity": false, "shell": false, + } { + if got := canFork(kind); got != want { + t.Errorf("canFork(%q) = %v, want %v", kind, got, want) + } + } +} + +func TestPlacedAfterPutsTheForkUnderItsParent(t *testing.T) { + cases := []struct { + ids []string + id, parent string + want []string + }{ + {[]string{"a", "p", "b", "f"}, "f", "p", []string{"a", "p", "f", "b"}}, + {[]string{"p", "a"}, "f", "p", []string{"p", "f", "a"}}, + {[]string{"a", "b"}, "f", "gone", []string{"a", "b", "f"}}, + } + for _, c := range cases { + if got := placedAfter(c.ids, c.id, c.parent); !slices.Equal(got, c.want) { + t.Errorf("placedAfter(%v, %q, %q) = %v, want %v", c.ids, c.id, c.parent, got, c.want) + } + } +} + +func TestTheSidebarKeepsACardsForkRequests(t *testing.T) { + var frame sidebarAction + frame.takeCard(sidebarAction{forkHere: "s1"}) + frame.takeCard(sidebarAction{forkWorktree: "s2"}) + if frame.forkHere != "s1" || frame.forkWorktree != "s2" { + t.Fatalf("frame action %+v, want both fork requests kept", frame) + } +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 3ef1c38d..f0a416e8 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -755,6 +755,12 @@ func (u *window) apply(act sidebarAction) { if act.worktree { u.showWorktreeDialog() } + if act.forkHere != "" { + go u.forkHere(act.forkHere) + } + if act.forkWorktree != "" { + u.showForkDialog(act.forkWorktree) + } if act.close != "" { u.closeSession(act.close) } diff --git a/native/cmd/lich-native/sessiontooltip.go b/native/cmd/lich-native/sessiontooltip.go index f76b411f..3a53a6fd 100644 --- a/native/cmd/lich-native/sessiontooltip.go +++ b/native/cmd/lich-native/sessiontooltip.go @@ -38,6 +38,9 @@ func sessionTooltip(th *ui.Theme, sv *sessionView) layout.Widget { th.Text(ui.TextXS, sv.Label).Weight(font.Medium).Layout, para(th.Text(ui.TextXS, path).Muted().Mono()).Layout, } + if sv.Forked && sv.OriginLabel != "" { + sections = append(sections, para(th.Text(ui.TextXS, i18n.T("sidebar.sessionTooltip.forkedFrom", i18n.Args{"label": sv.OriginLabel})).Muted()).Layout) + } if sv.relay.Direction != "" { sections = append(sections, relaySection(th, sv.relay)) } diff --git a/native/cmd/lich-native/sidebar.go b/native/cmd/lich-native/sidebar.go index 28490c56..19d9e8a5 100644 --- a/native/cmd/lich-native/sidebar.go +++ b/native/cmd/lich-native/sidebar.go @@ -126,6 +126,8 @@ type sidebarAction struct { color, colorTo string // session id, and its new tint ("" for the theme) continueIn continueRequest worktree bool // the + menu asked for a worktree session + // The session to fork, in its own checkout or into a new worktree. + forkHere, forkWorktree string sidebarWrites } @@ -187,6 +189,8 @@ func (a *sidebarAction) takeCard(c sidebarAction) { pick(&a.terminalAt, c.terminalAt) pick(&a.editorAt, c.editorAt) pick(&a.folderAt, c.folderAt) + pick(&a.forkHere, c.forkHere) + pick(&a.forkWorktree, c.forkWorktree) if c.pin != "" { a.pin, a.pinTo = c.pin, c.pinTo } diff --git a/native/cmd/lich-native/sidebar_card.go b/native/cmd/lich-native/sidebar_card.go index a4399c2f..bc0bf00a 100644 --- a/native/cmd/lich-native/sidebar_card.go +++ b/native/cmd/lich-native/sidebar_card.go @@ -131,7 +131,8 @@ func (s *sidebar) cardBody(gtx C, th *ui.Theme, sv *sessionView, prChip *widget. } // titleRow is the card's first line: the status ring around the provider -// mark, a shield on a session that runs sandboxed, and the label, which stops +// mark, a shield on a session that runs sandboxed, a fork on one born of a +// fork, and the label, which stops // short of the corner controls (pr-11, pr-6 for a pinned card's lone pin). func (s *sidebar) titleRow(th *ui.Theme, sv *sessionView) []layout.FlexChild { row := []layout.FlexChild{ @@ -141,6 +142,9 @@ func (s *sidebar) titleRow(th *ui.Theme, sv *sessionView) []layout.FlexChild { if sv.sandbox.Confined { row = append(row, layout.Rigid(func(gtx C) D { return s.icShield.Layout(gtx, 12, th.MutedForeground) }), ui.Gap(1.5)) } + if sv.Forked { + row = append(row, layout.Rigid(func(gtx C) D { return icMenuFork.Layout(gtx, 12, th.MutedForeground) }), ui.Gap(1.5)) + } if s.renaming(sv.ID) { return append(row, layout.Flexed(1, func(gtx C) D { return s.renameField(gtx, th, sv.ID, s.renames[sv.ID]) })) } diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index 47fd9d83..c5ea3f8c 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -105,6 +105,11 @@ type terminals struct { // sent right after Start; the PTY holds it until the shell reads it. typeahead map[string]string toaster *ui.Toaster + + // forks are the sessions whose first start branches a conversation, by + // the conversation: the provider assigns the copy its own id, reported by + // the session-start hook, so nothing here outlives that start. + forks map[string]string // profileGone takes a session whose Claude Code profile was deleted: its // spawn is refused, so a toast in place of the generic failure offers a // way on. @@ -121,6 +126,36 @@ func (ts *terminals) queue(id, text string) { ts.typeahead[id] += text } +// queueFork makes session id's first start branch the provider conversation +// parent rather than open a fresh one. +func (ts *terminals) queueFork(id, parent string) { + ts.mu.Lock() + defer ts.mu.Unlock() + if ts.forks == nil { + ts.forks = map[string]string{} + } + ts.forks[id] = parent +} + +// takeFork is the conversation session id's start branches, "" for none, +// and forgets it. +func (ts *terminals) takeFork(id string) string { + ts.mu.Lock() + defer ts.mu.Unlock() + parent := ts.forks[id] + delete(ts.forks, id) + return parent +} + +// start spawns s's process: a fork of the conversation queued for it, else +// resume (or a fresh one for ""). +func (ts *terminals) start(ctx context.Context, s sessionView, resume string, cols, rows int) error { + if parent := ts.takeFork(s.ID); parent != "" { + return ts.client.Fork(ctx, s.ID, s.projectID, s.path, s.Kind, parent, cols, rows) + } + return ts.client.Start(ctx, s.ID, s.projectID, s.path, s.Kind, resume, cols, rows) +} + func (ts *terminals) onOutput(id string, data []byte) { ts.mu.Lock() t, shown := ts.byID[id], ts.shown @@ -212,7 +247,7 @@ func (ts *terminals) show(ctx context.Context, gtx layout.Context, s sessionView ts.byID[s.ID] = t ts.mu.Unlock() // A failed start leaves the terminal in place, empty, as the web's does. - if err := ts.client.Start(ctx, s.ID, s.projectID, s.path, s.Kind, resume, cols, rows); err != nil { + if err := ts.start(ctx, s, resume, cols, rows); err != nil { if lichclient.ProfileGone(err) && ts.profileGone != nil { ts.profileGone(s) return t, nil diff --git a/native/cmd/lich-native/worktree_dialog.go b/native/cmd/lich-native/worktree_dialog.go index 26d047f2..af0b1460 100644 --- a/native/cmd/lich-native/worktree_dialog.go +++ b/native/cmd/lich-native/worktree_dialog.go @@ -54,7 +54,11 @@ type worktreeDialog struct { project lichclient.Project kind string - checkout lichclient.DiffStats // the project's own checkout, for the carry + source string // the checkout the carry copies from + checkout lichclient.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 @@ -77,15 +81,17 @@ func newWorktreeDialog(c *lichclient.Client, root *ui.Root, invalidate func()) * return &worktreeDialog{client: c, root: root, invalidate: invalidate} } -// show opens the dialog on p's checkout, for a session of kind, and starts -// the reads it draws: branches, the sandbox rung, the base's freshness. -func (d *worktreeDialog) show(ctx context.Context, p lichclient.Project, kind string, checkout lichclient.DiffStats) { +// show opens the dialog for a session of kind, based on source (a checkout +// 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) { d.open, d.focus, d.pickerOpen = true, true, false d.picker.Close() - d.project, d.kind, d.checkout = p, kind, checkout + d.project, d.kind, d.source, d.checkout, d.fork = p, kind, source, checkout, fork d.name.SetText("") d.search.SetText("") - d.carry.Value = false + d.carry.Value = fork != nil d.mu.Lock() d.branches, d.loadErr, d.base, d.checks = nil, "", "", map[string]*baseCheck{} d.issueRef, d.issue, d.issueErr = 0, nil, "" @@ -102,6 +108,10 @@ func (d *worktreeDialog) loadBranches(ctx context.Context) { if err != nil { d.loadErr = lichclient.ErrorText(err) } else { + // Picking a worktree opens its session, which forks nothing. + if d.fork != nil { + b.Worktrees = nil + } d.branches = &b if d.base == "" { d.base = preferredBase(b, d.checkout.Branch) @@ -291,15 +301,17 @@ type worktreeRequest struct { base string check *baseCheck carry bool + source string sandbox string brief string + fork *forkSource } // request reads the form into a worktreeRequest. func (d *worktreeDialog) request(v worktreeView) worktreeRequest { r := worktreeRequest{ project: d.project, kind: d.kind, name: v.newBranch(d.name.Text()), base: v.base, - check: v.check, carry: d.carry.Value && d.canCarry(v.base), + check: v.check, carry: d.carry.Value && d.canCarry(v.base), source: d.source, fork: d.fork, } if v.canConfine { r.sandbox = map[bool]string{true: "on", false: "off"}[d.confined.Value] @@ -326,6 +338,12 @@ func (u *window) openWorktree(r worktreeRequest) { u.reopenWorktree(r, path) return } + if r.fork != nil { + if err := u.conversationLeft(r.project, *r.fork); err != nil { + d.setSubmitting(err.Error()) + return + } + } if r.check != nil { <-r.check.done } @@ -336,9 +354,9 @@ func (u *window) openWorktree(r worktreeRequest) { return } if r.carry { - u.carryInto(r.project.Path, wt) + u.carryInto(r.source, wt) } - id, err := u.addWorktreeSession(r, wt, r.sandbox) + id, err := u.worktreeSession(r, wt) if err != nil { d.setSubmitting(lichclient.ErrorText(err)) return @@ -369,6 +387,15 @@ func (u *window) carryInto(from string, wt *lichclient.Worktree) { } } +// 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) { + if r.fork != nil { + return u.addForkSession(r.project, *r.fork, wt.Path) + } + return u.addWorktreeSession(r, wt, r.sandbox) +} + // 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) { @@ -445,7 +472,7 @@ func (u *window) showWorktreeDialog() { } u.settings.hide() checkout, _ := u.model.gitStatus(p.Path) - u.worktree.show(u.ctx, p, u.model.newSessionKind(), checkout) + u.worktree.show(u.ctx, p, u.model.newSessionKind(), p.Path, checkout, nil) // The git store reads the checkouts cards show; the project's own may // have none on screen, and the carry row needs where it stands now. go u.model.refreshPath(u.ctx, p.Path) diff --git a/native/cmd/lich-native/worktree_dialog_view.go b/native/cmd/lich-native/worktree_dialog_view.go index f0ace19c..f4e52d1a 100644 --- a/native/cmd/lich-native/worktree_dialog_view.go +++ b/native/cmd/lich-native/worktree_dialog_view.go @@ -108,7 +108,7 @@ func (u *window) layoutWorktreeDialog(gtx C, th *ui.Theme) { if d.view().done { d.open = false } - if checkout, ok := u.model.gitStatus(d.project.Path); ok { + if checkout, ok := u.model.gitStatus(d.source); ok { d.checkout = checkout } d.applyRung() @@ -117,7 +117,13 @@ func (u *window) layoutWorktreeDialog(gtx C, th *ui.Theme) { on := th.On(th.Popover) cancel := on.Button(&d.cancel, i18n.T("common.action.cancel")) cancel.Variant = ui.VariantGhost + title, description := i18n.T("sidebar.worktreeDialogView.title"), i18n.T("sidebar.worktreeDialogView.description") label := i18n.T("sidebar.worktreeDialogView.create") + if d.fork != nil { + title = i18n.T("sidebar.worktreeDialogView.forkTitle", i18n.Args{"label": d.fork.label}) + description = i18n.T("sidebar.worktreeDialogView.forkDescription") + label = i18n.T("sidebar.worktreeDialogView.fork") + } switch group, _ := splitBase(v.base); { case v.submitting: label = i18n.T("sidebar.worktreeDialog.creating") @@ -126,7 +132,7 @@ func (u *window) layoutWorktreeDialog(gtx C, th *ui.Theme) { } create := on.Button(&d.create, label) create.Disabled = v.base == "" || v.submitting - dlg := th.Dialog(&d.dialog, d.open, i18n.T("sidebar.worktreeDialogView.title"), i18n.T("sidebar.worktreeDialogView.description")) + dlg := th.Dialog(&d.dialog, d.open, title, description) dlg.Root = d.root dlg.Body = func(gtx C) D { return d.body(gtx, on, u.ctx, v) } dlg.Actions = []layout.Widget{cancel.Layout, create.Layout} @@ -226,7 +232,9 @@ func (d *worktreeDialog) body(gtx C, th *ui.Theme, ctx context.Context, v worktr if d.canCarry(v.base) { children = append(children, ui.VGap(3), layout.Rigid(func(gtx C) D { return d.carryRow(gtx, th) })) } - if v.canConfine && group != baseWorktree { + // A fork opens like a session another one asked for, on its provider's + // rung, so it has no box to tick. + if v.canConfine && group != baseWorktree && d.fork == nil { children = append(children, ui.VGap(3), layout.Rigid(func(gtx C) D { return checkRow(gtx, th, &d.confined, i18n.T("sidebar.worktreeSandboxRow.runConfined"), "") })) diff --git a/native/i18n/locales/en/sidebar.json b/native/i18n/locales/en/sidebar.json index c1ca1b37..0edc539d 100644 --- a/native/i18n/locales/en/sidebar.json +++ b/native/i18n/locales/en/sidebar.json @@ -38,7 +38,11 @@ "editor": "Editor", "fileManager": "File manager", "pullRequest": "Pull request", - "closeSession": "Close session" + "closeSession": "Close session", + "fork": "Fork", + "forkHere": "Here", + "forkWorktree": "Worktree…", + "forkResumeOnly": "resume only" }, "settingsCard": { "close": "Close settings" @@ -119,7 +123,8 @@ "confinedMeans": "Empty home, machine read-only, writes only in this checkout.", "setAtOpen": "Set when the session opened; reopen it to change.", "notMounted": "Not mounted (symlinks): {paths}", - "morePaths": "+{count} more" + "morePaths": "+{count} more", + "forkedFrom": "Forked from {label}" }, "worktreeDialogView": { "title": "New worktree session", @@ -141,7 +146,10 @@ "other": "{count} files from this checkout" }, "create": "Create", - "open": "Open" + "open": "Open", + "forkTitle": "Fork {label} to a worktree", + "forkDescription": "A separate checkout on its own branch.", + "fork": "Fork" }, "worktreeLogic": { "checking": "Checking the remote…", @@ -166,5 +174,9 @@ "dismiss": "Dismiss", "resumesIn": "It resumes in {profile} · {email}, the profile it started in.", "resumesInNoEmail": "It resumes in {profile}, the profile it started in." + }, + "fork": { + "pruned": "{name} has no conversation left to fork: {provider} pruned it.", + "failed": "Couldn't fork {name}: {error}" } } diff --git a/native/i18n/locales/es/sidebar.json b/native/i18n/locales/es/sidebar.json index ff9637ee..d63c441e 100644 --- a/native/i18n/locales/es/sidebar.json +++ b/native/i18n/locales/es/sidebar.json @@ -38,7 +38,11 @@ "editor": "Editor", "fileManager": "Administrador de archivos", "pullRequest": "Pull request", - "closeSession": "Cerrar sesión" + "closeSession": "Cerrar sesión", + "fork": "Bifurcar", + "forkHere": "Aquí", + "forkWorktree": "Worktree…", + "forkResumeOnly": "solo reanuda" }, "settingsCard": { "close": "Cerrar ajustes" @@ -119,7 +123,8 @@ "confinedMeans": "Home vacío, máquina en solo lectura, escritura solo en este checkout.", "setAtOpen": "Se define al abrir la sesión; vuelve a abrirla para cambiarlo.", "notMounted": "No montado (enlaces simbólicos): {paths}", - "morePaths": "+{count} más" + "morePaths": "+{count} más", + "forkedFrom": "Bifurcada de {label}" }, "worktreeDialogView": { "title": "Nueva sesión en worktree", @@ -141,7 +146,10 @@ "other": "{count} archivos de este checkout" }, "create": "Crear", - "open": "Abrir" + "open": "Abrir", + "forkTitle": "Bifurcar {label} a un worktree", + "forkDescription": "Un checkout aparte, en su propia rama.", + "fork": "Bifurcar" }, "worktreeLogic": { "checking": "Comprobando el remoto…", @@ -166,5 +174,9 @@ "dismiss": "Descartar", "resumesIn": "Se reanuda en {profile} · {email}, el perfil en el que empezó.", "resumesInNoEmail": "Se reanuda en {profile}, el perfil en el que empezó." + }, + "fork": { + "pruned": "{name} ya no tiene una conversación que bifurcar: {provider} la borró.", + "failed": "No se pudo bifurcar {name}: {error}" } } diff --git a/native/i18n/locales/ja/sidebar.json b/native/i18n/locales/ja/sidebar.json index 0455694c..a3e0a82f 100644 --- a/native/i18n/locales/ja/sidebar.json +++ b/native/i18n/locales/ja/sidebar.json @@ -38,7 +38,11 @@ "editor": "エディター", "fileManager": "ファイルマネージャー", "pullRequest": "プルリクエスト", - "closeSession": "セッションを閉じる" + "closeSession": "セッションを閉じる", + "fork": "フォーク", + "forkHere": "ここで", + "forkWorktree": "ワークツリー…", + "forkResumeOnly": "再開のみ" }, "settingsCard": { "close": "設定を閉じる" @@ -119,7 +123,8 @@ "confinedMeans": "ホームは空、マシンは読み取り専用で、書き込みはこのチェックアウト内のみです。", "setAtOpen": "セッションを開いたときに決まります。変更するには開き直してください。", "notMounted": "マウントされていません (シンボリックリンク): {paths}", - "morePaths": "ほか {count} 件" + "morePaths": "ほか {count} 件", + "forkedFrom": "{label} からフォーク" }, "worktreeDialogView": { "title": "新しいワークツリーセッション", @@ -141,7 +146,10 @@ "other": "このチェックアウトのファイル {count} 件" }, "create": "作成", - "open": "開く" + "open": "開く", + "forkTitle": "{label} をワークツリーにフォーク", + "forkDescription": "独自のブランチを持つ別のチェックアウト。", + "fork": "フォーク" }, "worktreeLogic": { "checking": "リモートを確認しています…", @@ -166,5 +174,9 @@ "dismiss": "閉じる", "resumesIn": "開始時のプロファイルである {profile} · {email} で再開します。", "resumesInNoEmail": "開始時のプロファイルである {profile} で再開します。" + }, + "fork": { + "pruned": "{name} にはフォークできる会話がもうありません: {provider} が削除しました。", + "failed": "{name} をフォークできませんでした: {error}" } } diff --git a/native/i18n/locales/ko/sidebar.json b/native/i18n/locales/ko/sidebar.json index 3c19e4a4..20eb6c9e 100644 --- a/native/i18n/locales/ko/sidebar.json +++ b/native/i18n/locales/ko/sidebar.json @@ -37,7 +37,11 @@ "editor": "에디터", "fileManager": "파일 관리자", "pullRequest": "풀 리퀘스트", - "closeSession": "세션 닫기" + "closeSession": "세션 닫기", + "fork": "포크", + "forkHere": "여기서", + "forkWorktree": "worktree…", + "forkResumeOnly": "재개만 가능" }, "settingsCard": { "close": "설정 닫기" @@ -118,7 +122,8 @@ "confinedMeans": "빈 홈, 읽기 전용 머신, 이 체크아웃 안에서만 쓰기 가능.", "setAtOpen": "세션을 열 때 정해집니다. 바꾸려면 다시 여세요.", "notMounted": "마운트되지 않음(심볼릭 링크): {paths}", - "morePaths": "+{count}개 더" + "morePaths": "+{count}개 더", + "forkedFrom": "{label}에서 포크됨" }, "worktreeDialogView": { "title": "새 worktree 세션", @@ -139,7 +144,10 @@ "other": "이 체크아웃의 파일 {count}개" }, "create": "만들기", - "open": "열기" + "open": "열기", + "forkTitle": "{label}을(를) worktree로 포크", + "forkDescription": "자체 브랜치를 가진 별도의 체크아웃입니다.", + "fork": "포크" }, "worktreeLogic": { "checking": "원격 확인 중…", @@ -163,5 +171,9 @@ "dismiss": "닫기", "resumesIn": "처음 시작한 프로필인 {profile} · {email}에서 재개됩니다.", "resumesInNoEmail": "처음 시작한 프로필인 {profile}에서 재개됩니다." + }, + "fork": { + "pruned": "{name}에는 포크할 대화가 더 이상 없습니다: {provider}이(가) 삭제했습니다.", + "failed": "{name}을(를) 포크하지 못했습니다: {error}" } } diff --git a/native/i18n/locales/pt-br/sidebar.json b/native/i18n/locales/pt-br/sidebar.json index 6278cd61..9b70c487 100644 --- a/native/i18n/locales/pt-br/sidebar.json +++ b/native/i18n/locales/pt-br/sidebar.json @@ -38,7 +38,11 @@ "editor": "Editor", "fileManager": "Gerenciador de arquivos", "pullRequest": "Pull request", - "closeSession": "Fechar sessão" + "closeSession": "Fechar sessão", + "fork": "Fork", + "forkHere": "Aqui", + "forkWorktree": "Worktree…", + "forkResumeOnly": "só retoma" }, "settingsCard": { "close": "Fechar configurações" @@ -119,7 +123,8 @@ "confinedMeans": "Home vazia, máquina somente leitura, escrita só neste checkout.", "setAtOpen": "Definido quando a sessão abriu; reabra-a para mudar.", "notMounted": "Não montados (symlinks): {paths}", - "morePaths": "+{count} a mais" + "morePaths": "+{count} a mais", + "forkedFrom": "Fork de {label}" }, "worktreeDialogView": { "title": "Nova sessão em worktree", @@ -141,7 +146,10 @@ "other": "{count} arquivos deste checkout" }, "create": "Criar", - "open": "Abrir" + "open": "Abrir", + "forkTitle": "Fork de {label} para uma worktree", + "forkDescription": "Um checkout separado, na própria branch.", + "fork": "Fazer fork" }, "worktreeLogic": { "checking": "Verificando o remoto…", @@ -166,5 +174,9 @@ "dismiss": "Dispensar", "resumesIn": "Ela é retomada em {profile} · {email}, o perfil em que começou.", "resumesInNoEmail": "Ela é retomada em {profile}, o perfil em que começou." + }, + "fork": { + "pruned": "{name} não tem mais conversa para fazer fork: o {provider} a apagou.", + "failed": "Não foi possível fazer fork de {name}: {error}" } } diff --git a/native/i18n/locales/ru/sidebar.json b/native/i18n/locales/ru/sidebar.json index 93ffb22b..21819457 100644 --- a/native/i18n/locales/ru/sidebar.json +++ b/native/i18n/locales/ru/sidebar.json @@ -40,7 +40,11 @@ "editor": "Редактор", "fileManager": "Файловый менеджер", "pullRequest": "Pull request", - "closeSession": "Закрыть сессию" + "closeSession": "Закрыть сессию", + "fork": "Форк", + "forkHere": "Здесь", + "forkWorktree": "Worktree…", + "forkResumeOnly": "только возобновление" }, "settingsCard": { "close": "Закрыть настройки" @@ -121,7 +125,8 @@ "confinedMeans": "Пустой домашний каталог, система только для чтения, запись только в эту рабочую копию.", "setAtOpen": "Задаётся при открытии сессии; чтобы изменить, откройте её заново.", "notMounted": "Не смонтировано (симлинки): {paths}", - "morePaths": "+ещё {count}" + "morePaths": "+ещё {count}", + "forkedFrom": "Форк от {label}" }, "worktreeDialogView": { "title": "Новая сессия в worktree", @@ -145,7 +150,10 @@ "other": "{count} файла из этой рабочей копии" }, "create": "Создать", - "open": "Открыть" + "open": "Открыть", + "forkTitle": "Форк {label} в worktree", + "forkDescription": "Отдельный checkout в собственной ветке.", + "fork": "Форк" }, "worktreeLogic": { "checking": "Проверка удалённого репозитория…", @@ -172,5 +180,9 @@ "dismiss": "Скрыть", "resumesIn": "Возобновится в {profile} · {email}, профиле, в котором была запущена.", "resumesInNoEmail": "Возобновится в {profile}, профиле, в котором была запущена." + }, + "fork": { + "pruned": "У {name} больше нет разговора для форка: {provider} его удалил.", + "failed": "Не удалось сделать форк {name}: {error}" } } diff --git a/native/i18n/locales/zh-cn/sidebar.json b/native/i18n/locales/zh-cn/sidebar.json index 4df22811..2edf593b 100644 --- a/native/i18n/locales/zh-cn/sidebar.json +++ b/native/i18n/locales/zh-cn/sidebar.json @@ -38,7 +38,11 @@ "editor": "编辑器", "fileManager": "文件管理器", "pullRequest": "拉取请求", - "closeSession": "关闭会话" + "closeSession": "关闭会话", + "fork": "分叉", + "forkHere": "在此处", + "forkWorktree": "Worktree…", + "forkResumeOnly": "仅可恢复" }, "settingsCard": { "close": "关闭设置" @@ -119,7 +123,8 @@ "confinedMeans": "主目录为空,机器只读,仅可写入此检出。", "setAtOpen": "会话打开时确定;重新打开以更改。", "notMounted": "未挂载(符号链接):{paths}", - "morePaths": "另外 {count} 项" + "morePaths": "另外 {count} 项", + "forkedFrom": "分叉自 {label}" }, "worktreeDialogView": { "title": "新建工作树会话", @@ -141,7 +146,10 @@ "other": "此检出中的 {count} 个文件" }, "create": "创建", - "open": "打开" + "open": "打开", + "forkTitle": "将 {label} 分叉到 worktree", + "forkDescription": "一个独立的检出,位于自己的分支上。", + "fork": "分叉" }, "worktreeLogic": { "checking": "正在检查远程…", @@ -166,5 +174,9 @@ "dismiss": "忽略", "resumesIn": "它将在 {profile} · {email} 中恢复,即它启动时所用的配置档案。", "resumesInNoEmail": "它将在 {profile} 中恢复,即它启动时所用的配置档案。" + }, + "fork": { + "pruned": "{name} 已没有可分叉的对话:{provider} 已将其删除。", + "failed": "无法分叉 {name}:{error}" } } diff --git a/native/lichclient/api.go b/native/lichclient/api.go index af0dfe85..7a33ce39 100644 --- a/native/lichclient/api.go +++ b/native/lichclient/api.go @@ -38,6 +38,12 @@ type Session struct { // Profile is the Claude Code profile the session runs in (a slug), "" for // Personal and for every other provider. Profile string `json:"profile"` + // OriginLabel is what the session this one came from was called when it + // did, "" for one nobody opened from another. + OriginLabel string `json:"originLabel"` + // Forked is whether the session was born branching that session's + // conversation, rather than delegated or opened as a subagent. + Forked bool `json:"forked"` } // DiffStats mirrors project.DiffStats. @@ -90,6 +96,13 @@ func (c *Client) Start(ctx context.Context, id, projectID, cwd, kind, resume str return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, resume, "", false, false, cols, rows) } +// Fork spawns the session's process branching the provider conversation +// parent: the conversation is copied into one of its own, and the card that +// runs parent goes on untouched. A no-op when the session already runs. +func (c *Client) Fork(ctx context.Context, id, projectID, cwd, kind, parent string, cols, rows int) error { + return c.Call(ctx, "terminal.Start", nil, id, projectID, cwd, kind, parent, "", true, false, cols, rows) +} + // Resize sets the session's PTY size. func (c *Client) Resize(ctx context.Context, id string, cols, rows int) error { return c.Call(ctx, "terminal.Resize", nil, id, cols, rows) diff --git a/native/lichclient/store.go b/native/lichclient/store.go index 40b58513..95db3687 100644 --- a/native/lichclient/store.go +++ b/native/lichclient/store.go @@ -34,6 +34,18 @@ func (c *Client) RenameFolder(ctx context.Context, projectID, from, to string) ( return moved, c.Call(ctx, "store.RenameFolder", &moved, projectID, from, 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) +} + +// 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) +} + // 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.