Просмотр исходного кода

feat(tui): fork slash command for keyboard-friendly session forking (resolves #5599) (#5610)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Ariane Emory 2 месяцев назад
Родитель
Сommit
7437ccd6f4

+ 5 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -270,6 +270,11 @@ export function Autocomplete(props: {
           description: "jump to message",
           description: "jump to message",
           onSelect: () => command.trigger("session.timeline"),
           onSelect: () => command.trigger("session.timeline"),
         },
         },
+        {
+          display: "/fork",
+          description: "fork from message",
+          onSelect: () => command.trigger("session.fork"),
+        },
         {
         {
           display: "/thinking",
           display: "/thinking",
           description: "toggle thinking visibility",
           description: "toggle thinking visibility",

+ 54 - 0
packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx

@@ -0,0 +1,54 @@
+import { createMemo, onMount } from "solid-js"
+import { useSync } from "@tui/context/sync"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import type { TextPart } from "@opencode-ai/sdk/v2"
+import { Locale } from "@/util/locale"
+import { useSDK } from "@tui/context/sdk"
+import { useRoute } from "@tui/context/route"
+import { useDialog } from "../../ui/dialog"
+
+export function DialogForkFromTimeline(props: {
+  sessionID: string
+  onMove: (messageID: string) => void
+}) {
+  const sync = useSync()
+  const dialog = useDialog()
+  const sdk = useSDK()
+  const route = useRoute()
+
+  onMount(() => {
+    dialog.setSize("large")
+  })
+
+  const options = createMemo((): DialogSelectOption<string>[] => {
+    const messages = sync.data.message[props.sessionID] ?? []
+    const result = [] as DialogSelectOption<string>[]
+    for (const message of messages) {
+      if (message.role !== "user") continue
+      const part = (sync.data.part[message.id] ?? []).find(
+        (x) => x.type === "text" && !x.synthetic && !x.ignored,
+      ) as TextPart
+      if (!part) continue
+      result.push({
+        title: part.text.replace(/\n/g, " "),
+        value: message.id,
+        footer: Locale.time(message.time.created),
+        onSelect: async (dialog) => {
+          const forked = await sdk.client.session.fork({
+            sessionID: props.sessionID,
+            messageID: message.id,
+          })
+          route.navigate({
+            sessionID: forked.data!.id,
+            type: "session",
+          })
+          dialog.clear()
+        },
+      })
+    }
+    result.reverse()
+    return result
+  })
+
+  return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
+}

+ 1 - 1
packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx

@@ -24,7 +24,7 @@ export function DialogTimeline(props: {
     const result = [] as DialogSelectOption<string>[]
     const result = [] as DialogSelectOption<string>[]
     for (const message of messages) {
     for (const message of messages) {
       if (message.role !== "user") continue
       if (message.role !== "user") continue
-      const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
+      const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic && !x.ignored,) as TextPart
       if (!part) continue
       if (!part) continue
       result.push({
       result.push({
         title: part.text.replace(/\n/g, " "),
         title: part.text.replace(/\n/g, " "),

+ 20 - 0
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -53,6 +53,7 @@ import { iife } from "@/util/iife"
 import { DialogConfirm } from "@tui/ui/dialog-confirm"
 import { DialogConfirm } from "@tui/ui/dialog-confirm"
 import { DialogPrompt } from "@tui/ui/dialog-prompt"
 import { DialogPrompt } from "@tui/ui/dialog-prompt"
 import { DialogTimeline } from "./dialog-timeline"
 import { DialogTimeline } from "./dialog-timeline"
+import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
 import { DialogSessionRename } from "../../component/dialog-session-rename"
 import { DialogSessionRename } from "../../component/dialog-session-rename"
 import { Sidebar } from "./sidebar"
 import { Sidebar } from "./sidebar"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
@@ -295,6 +296,25 @@ export function Session() {
         ))
         ))
       },
       },
     },
     },
+    {
+      title: "Fork from message",
+      value: "session.fork",
+      keybind: "session_fork",
+      category: "Session",
+      onSelect: (dialog) => {
+        dialog.replace(() => (
+          <DialogForkFromTimeline
+            onMove={(messageID) => {
+              const child = scroll.getChildren().find((child) => {
+                return child.id === messageID
+              })
+              if (child) scroll.scrollBy(child.y - scroll.y - 1)
+            }}
+            sessionID={route.sessionID}
+          />
+        ))
+      },
+    },
     {
     {
       title: "Compact session",
       title: "Compact session",
       value: "session.compact",
       value: "session.compact",

+ 2 - 0
packages/opencode/src/config/config.ts

@@ -440,6 +440,8 @@ export namespace Config {
       session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
       session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
       session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
       session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
       session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
       session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
+      session_fork: z.string().optional().default("none").describe("Fork session from message"),
+      session_rename: z.string().optional().default("none").describe("Rename session"),
       session_share: z.string().optional().default("none").describe("Share current session"),
       session_share: z.string().optional().default("none").describe("Share current session"),
       session_unshare: z.string().optional().default("none").describe("Unshare current session"),
       session_unshare: z.string().optional().default("none").describe("Unshare current session"),
       session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
       session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),