Procházet zdrojové kódy

refactor(tui): unify command registry and derive slash commands (#9115)

Kit Langton před 1 měsícem
rodič
revize
759e68616e

+ 41 - 4
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -288,6 +288,10 @@ function App() {
       keybind: "session_list",
       category: "Session",
       suggested: sync.data.session.length > 0,
+      slash: {
+        name: "sessions",
+        aliases: ["resume", "continue"],
+      },
       onSelect: () => {
         dialog.replace(() => <DialogSessionList />)
       },
@@ -298,6 +302,10 @@ function App() {
       value: "session.new",
       keybind: "session_new",
       category: "Session",
+      slash: {
+        name: "new",
+        aliases: ["clear"],
+      },
       onSelect: () => {
         const current = promptRef.current
         // Don't require focus - if there's any text, preserve it
@@ -315,26 +323,29 @@ function App() {
       keybind: "model_list",
       suggested: true,
       category: "Agent",
+      slash: {
+        name: "models",
+      },
       onSelect: () => {
         dialog.replace(() => <DialogModel />)
       },
     },
     {
       title: "Model cycle",
-      disabled: true,
       value: "model.cycle_recent",
       keybind: "model_cycle_recent",
       category: "Agent",
+      hidden: true,
       onSelect: () => {
         local.model.cycle(1)
       },
     },
     {
       title: "Model cycle reverse",
-      disabled: true,
       value: "model.cycle_recent_reverse",
       keybind: "model_cycle_recent_reverse",
       category: "Agent",
+      hidden: true,
       onSelect: () => {
         local.model.cycle(-1)
       },
@@ -344,6 +355,7 @@ function App() {
       value: "model.cycle_favorite",
       keybind: "model_cycle_favorite",
       category: "Agent",
+      hidden: true,
       onSelect: () => {
         local.model.cycleFavorite(1)
       },
@@ -353,6 +365,7 @@ function App() {
       value: "model.cycle_favorite_reverse",
       keybind: "model_cycle_favorite_reverse",
       category: "Agent",
+      hidden: true,
       onSelect: () => {
         local.model.cycleFavorite(-1)
       },
@@ -362,6 +375,9 @@ function App() {
       value: "agent.list",
       keybind: "agent_list",
       category: "Agent",
+      slash: {
+        name: "agents",
+      },
       onSelect: () => {
         dialog.replace(() => <DialogAgent />)
       },
@@ -370,6 +386,9 @@ function App() {
       title: "Toggle MCPs",
       value: "mcp.list",
       category: "Agent",
+      slash: {
+        name: "mcps",
+      },
       onSelect: () => {
         dialog.replace(() => <DialogMcp />)
       },
@@ -379,7 +398,7 @@ function App() {
       value: "agent.cycle",
       keybind: "agent_cycle",
       category: "Agent",
-      disabled: true,
+      hidden: true,
       onSelect: () => {
         local.agent.move(1)
       },
@@ -389,6 +408,7 @@ function App() {
       value: "variant.cycle",
       keybind: "variant_cycle",
       category: "Agent",
+      hidden: true,
       onSelect: () => {
         local.model.variant.cycle()
       },
@@ -398,7 +418,7 @@ function App() {
       value: "agent.cycle.reverse",
       keybind: "agent_cycle_reverse",
       category: "Agent",
-      disabled: true,
+      hidden: true,
       onSelect: () => {
         local.agent.move(-1)
       },
@@ -407,6 +427,9 @@ function App() {
       title: "Connect provider",
       value: "provider.connect",
       suggested: !connected(),
+      slash: {
+        name: "connect",
+      },
       onSelect: () => {
         dialog.replace(() => <DialogProviderList />)
       },
@@ -416,6 +439,9 @@ function App() {
       title: "View status",
       keybind: "status_view",
       value: "opencode.status",
+      slash: {
+        name: "status",
+      },
       onSelect: () => {
         dialog.replace(() => <DialogStatus />)
       },
@@ -425,6 +451,9 @@ function App() {
       title: "Switch theme",
       value: "theme.switch",
       keybind: "theme_list",
+      slash: {
+        name: "themes",
+      },
       onSelect: () => {
         dialog.replace(() => <DialogThemeList />)
       },
@@ -442,6 +471,9 @@ function App() {
     {
       title: "Help",
       value: "help.show",
+      slash: {
+        name: "help",
+      },
       onSelect: () => {
         dialog.replace(() => <DialogHelp />)
       },
@@ -468,6 +500,10 @@ function App() {
     {
       title: "Exit the app",
       value: "app.exit",
+      slash: {
+        name: "exit",
+        aliases: ["quit", "q"],
+      },
       onSelect: () => exit(),
       category: "System",
     },
@@ -508,6 +544,7 @@ function App() {
       value: "terminal.suspend",
       keybind: "terminal_suspend",
       category: "System",
+      hidden: true,
       onSelect: () => {
         process.once("SIGCONT", () => {
           renderer.resume()

+ 52 - 28
packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx

@@ -16,9 +16,17 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
 type Context = ReturnType<typeof init>
 const ctx = createContext<Context>()
 
-export type CommandOption = DialogSelectOption & {
+export type Slash = {
+  name: string
+  aliases?: string[]
+}
+
+export type CommandOption = DialogSelectOption<string> & {
   keybind?: keyof KeybindsConfig
   suggested?: boolean
+  slash?: Slash
+  hidden?: boolean
+  enabled?: boolean
 }
 
 function init() {
@@ -26,27 +34,35 @@ function init() {
   const [suspendCount, setSuspendCount] = createSignal(0)
   const dialog = useDialog()
   const keybind = useKeybind()
-  const options = createMemo(() => {
+
+  const entries = createMemo(() => {
     const all = registrations().flatMap((x) => x())
-    const suggested = all.filter((x) => x.suggested)
-    return [
-      ...suggested.map((x) => ({
-        ...x,
-        category: "Suggested",
-        value: "suggested." + x.value,
-      })),
-      ...all,
-    ].map((x) => ({
+    return all.map((x) => ({
       ...x,
       footer: x.keybind ? keybind.print(x.keybind) : undefined,
     }))
   })
+
+  const isEnabled = (option: CommandOption) => option.enabled !== false
+  const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
+
+  const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
+  const suggestedOptions = createMemo(() =>
+    visibleOptions()
+      .filter((option) => option.suggested)
+      .map((option) => ({
+        ...option,
+        value: `suggested:${option.value}`,
+        category: "Suggested",
+      })),
+  )
   const suspended = () => suspendCount() > 0
 
   useKeyboard((evt) => {
     if (suspended()) return
     if (dialog.stack.length > 0) return
-    for (const option of options()) {
+    for (const option of entries()) {
+      if (!isEnabled(option)) continue
       if (option.keybind && keybind.match(option.keybind, evt)) {
         evt.preventDefault()
         option.onSelect?.(dialog)
@@ -56,20 +72,33 @@ function init() {
   })
 
   const result = {
-    trigger(name: string, source?: "prompt") {
-      for (const option of options()) {
+    trigger(name: string) {
+      for (const option of entries()) {
         if (option.value === name) {
-          option.onSelect?.(dialog, source)
+          if (!isEnabled(option)) return
+          option.onSelect?.(dialog)
           return
         }
       }
     },
+    slashes() {
+      return visibleOptions().flatMap((option) => {
+        const slash = option.slash
+        if (!slash) return []
+        return {
+          display: "/" + slash.name,
+          description: option.description ?? option.title,
+          aliases: slash.aliases?.map((alias) => "/" + alias),
+          onSelect: () => result.trigger(option.value),
+        }
+      })
+    },
     keybinds(enabled: boolean) {
       setSuspendCount((count) => count + (enabled ? -1 : 1))
     },
     suspended,
     show() {
-      dialog.replace(() => <DialogCommand options={options()} />)
+      dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
     },
     register(cb: () => CommandOption[]) {
       const results = createMemo(cb)
@@ -78,9 +107,6 @@ function init() {
         setRegistrations((arr) => arr.filter((x) => x !== results))
       })
     },
-    get options() {
-      return options()
-    },
   }
   return result
 }
@@ -104,7 +130,7 @@ export function CommandProvider(props: ParentProps) {
     if (evt.defaultPrevented) return
     if (keybind.match("command_list", evt)) {
       evt.preventDefault()
-      dialog.replace(() => <DialogCommand options={value.options} />)
+      value.show()
       return
     }
   })
@@ -112,13 +138,11 @@ export function CommandProvider(props: ParentProps) {
   return <ctx.Provider value={value}>{props.children}</ctx.Provider>
 }
 
-function DialogCommand(props: { options: CommandOption[] }) {
+function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
   let ref: DialogSelectRef<string>
-  return (
-    <DialogSelect
-      ref={(r) => (ref = r)}
-      title="Commands"
-      options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))}
-    />
-  )
+  const list = () => {
+    if (ref?.filter) return props.options
+    return [...props.suggestedOptions, ...props.options]
+  }
+  return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
 }

+ 9 - 140
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -332,16 +332,15 @@ export function Autocomplete(props: {
       )
   })
 
-  const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined))
   const commands = createMemo((): AutocompleteOption[] => {
-    const results: AutocompleteOption[] = []
-    const s = session()
-    for (const command of sync.data.command) {
+    const results: AutocompleteOption[] = [...command.slashes()]
+
+    for (const serverCommand of sync.data.command) {
       results.push({
-        display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
-        description: command.description,
+        display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
+        description: serverCommand.description,
         onSelect: () => {
-          const newText = "/" + command.name + " "
+          const newText = "/" + serverCommand.name + " "
           const cursor = props.input().logicalCursor
           props.input().deleteRange(0, 0, cursor.row, cursor.col)
           props.input().insertText(newText)
@@ -349,138 +348,9 @@ export function Autocomplete(props: {
         },
       })
     }
-    if (s) {
-      results.push(
-        {
-          display: "/undo",
-          description: "undo the last message",
-          onSelect: () => {
-            command.trigger("session.undo")
-          },
-        },
-        {
-          display: "/redo",
-          description: "redo the last message",
-          onSelect: () => command.trigger("session.redo"),
-        },
-        {
-          display: "/compact",
-          aliases: ["/summarize"],
-          description: "compact the session",
-          onSelect: () => command.trigger("session.compact"),
-        },
-        {
-          display: "/unshare",
-          disabled: !s.share,
-          description: "unshare a session",
-          onSelect: () => command.trigger("session.unshare"),
-        },
-        {
-          display: "/rename",
-          description: "rename session",
-          onSelect: () => command.trigger("session.rename"),
-        },
-        {
-          display: "/copy",
-          description: "copy session transcript to clipboard",
-          onSelect: () => command.trigger("session.copy"),
-        },
-        {
-          display: "/export",
-          description: "export session transcript to file",
-          onSelect: () => command.trigger("session.export"),
-        },
-        {
-          display: "/timeline",
-          description: "jump to message",
-          onSelect: () => command.trigger("session.timeline"),
-        },
-        {
-          display: "/fork",
-          description: "fork from message",
-          onSelect: () => command.trigger("session.fork"),
-        },
-        {
-          display: "/thinking",
-          description: "toggle thinking visibility",
-          onSelect: () => command.trigger("session.toggle.thinking"),
-        },
-      )
-      if (sync.data.config.share !== "disabled") {
-        results.push({
-          display: "/share",
-          disabled: !!s.share?.url,
-          description: "share a session",
-          onSelect: () => command.trigger("session.share"),
-        })
-      }
-    }
 
-    results.push(
-      {
-        display: "/new",
-        aliases: ["/clear"],
-        description: "create a new session",
-        onSelect: () => command.trigger("session.new"),
-      },
-      {
-        display: "/models",
-        description: "list models",
-        onSelect: () => command.trigger("model.list"),
-      },
-      {
-        display: "/agents",
-        description: "list agents",
-        onSelect: () => command.trigger("agent.list"),
-      },
-      {
-        display: "/session",
-        aliases: ["/resume", "/continue"],
-        description: "list sessions",
-        onSelect: () => command.trigger("session.list"),
-      },
-      {
-        display: "/status",
-        description: "show status",
-        onSelect: () => command.trigger("opencode.status"),
-      },
-      {
-        display: "/mcp",
-        description: "toggle MCPs",
-        onSelect: () => command.trigger("mcp.list"),
-      },
-      {
-        display: "/theme",
-        description: "toggle theme",
-        onSelect: () => command.trigger("theme.switch"),
-      },
-      {
-        display: "/editor",
-        description: "open editor",
-        onSelect: () => command.trigger("prompt.editor", "prompt"),
-      },
-      {
-        display: "/connect",
-        description: "connect to a provider",
-        onSelect: () => command.trigger("provider.connect"),
-      },
-      {
-        display: "/help",
-        description: "show help",
-        onSelect: () => command.trigger("help.show"),
-      },
-      {
-        display: "/commands",
-        description: "show all commands",
-        onSelect: () => command.show(),
-      },
-      {
-        display: "/exit",
-        aliases: ["/quit", "/q"],
-        description: "exit the app",
-        onSelect: () => command.trigger("app.exit"),
-      },
-    )
+    results.sort((a, b) => a.display.localeCompare(b.display))
+
     const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
     if (!max) return results
     return results.map((item) => ({
@@ -494,9 +364,8 @@ export function Autocomplete(props: {
     const agentsValue = agents()
     const commandsValue = commands()
 
-    const mixed: AutocompleteOption[] = (
+    const mixed: AutocompleteOption[] =
       store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
-    ).filter((x) => x.disabled !== true)
 
     const currentFilter = filter()
 

+ 13 - 9
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -157,7 +157,7 @@ export function Prompt(props: PromptProps) {
         title: "Clear prompt",
         value: "prompt.clear",
         category: "Prompt",
-        disabled: true,
+        hidden: true,
         onSelect: (dialog) => {
           input.extmarks.clear()
           input.clear()
@@ -167,9 +167,9 @@ export function Prompt(props: PromptProps) {
       {
         title: "Submit prompt",
         value: "prompt.submit",
-        disabled: true,
         keybind: "input_submit",
         category: "Prompt",
+        hidden: true,
         onSelect: (dialog) => {
           if (!input.focused) return
           submit()
@@ -179,9 +179,9 @@ export function Prompt(props: PromptProps) {
       {
         title: "Paste",
         value: "prompt.paste",
-        disabled: true,
         keybind: "input_paste",
         category: "Prompt",
+        hidden: true,
         onSelect: async () => {
           const content = await Clipboard.read()
           if (content?.mime.startsWith("image/")) {
@@ -197,8 +197,9 @@ export function Prompt(props: PromptProps) {
         title: "Interrupt session",
         value: "session.interrupt",
         keybind: "session_interrupt",
-        disabled: status().type === "idle",
         category: "Session",
+        hidden: true,
+        enabled: status().type !== "idle",
         onSelect: (dialog) => {
           if (autocomplete.visible) return
           if (!input.focused) return
@@ -229,7 +230,10 @@ export function Prompt(props: PromptProps) {
         category: "Session",
         keybind: "editor_open",
         value: "prompt.editor",
-        onSelect: async (dialog, trigger) => {
+        slash: {
+          name: "editor",
+        },
+        onSelect: async (dialog) => {
           dialog.clear()
 
           // replace summarized text parts with the actual text
@@ -242,7 +246,7 @@ export function Prompt(props: PromptProps) {
 
           const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
 
-          const value = trigger === "prompt" ? "" : text
+          const value = text
           const content = await Editor.open({ value, renderer })
           if (!content) return
 
@@ -432,7 +436,7 @@ export function Prompt(props: PromptProps) {
       title: "Stash prompt",
       value: "prompt.stash",
       category: "Prompt",
-      disabled: !store.prompt.input,
+      enabled: !!store.prompt.input,
       onSelect: (dialog) => {
         if (!store.prompt.input) return
         stash.push({
@@ -450,7 +454,7 @@ export function Prompt(props: PromptProps) {
       title: "Stash pop",
       value: "prompt.stash.pop",
       category: "Prompt",
-      disabled: stash.list().length === 0,
+      enabled: stash.list().length > 0,
       onSelect: (dialog) => {
         const entry = stash.pop()
         if (entry) {
@@ -466,7 +470,7 @@ export function Prompt(props: PromptProps) {
       title: "Stash list",
       value: "prompt.stash.list",
       category: "Prompt",
-      disabled: stash.list().length === 0,
+      enabled: stash.list().length > 0,
       onSelect: (dialog) => {
         dialog.replace(() => (
           <DialogStash

+ 78 - 40
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -295,37 +295,39 @@ export function Session() {
 
   const command = useCommandDialog()
   command.register(() => [
-    ...(sync.data.config.share !== "disabled"
-      ? [
-          {
-            title: "Share session",
-            value: "session.share",
-            suggested: route.type === "session",
-            keybind: "session_share" as const,
-            disabled: !!session()?.share?.url,
-            category: "Session",
-            onSelect: async (dialog: any) => {
-              await sdk.client.session
-                .share({
-                  sessionID: route.sessionID,
-                })
-                .then((res) =>
-                  Clipboard.copy(res.data!.share!.url).catch(() =>
-                    toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
-                  ),
-                )
-                .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
-                .catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
-              dialog.clear()
-            },
-          },
-        ]
-      : []),
+    {
+      title: "Share session",
+      value: "session.share",
+      suggested: route.type === "session",
+      keybind: "session_share",
+      category: "Session",
+      enabled: sync.data.config.share !== "disabled" && !session()?.share?.url,
+      slash: {
+        name: "share",
+      },
+      onSelect: async (dialog) => {
+        await sdk.client.session
+          .share({
+            sessionID: route.sessionID,
+          })
+          .then((res) =>
+            Clipboard.copy(res.data!.share!.url).catch(() =>
+              toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }),
+            ),
+          )
+          .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
+          .catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
+        dialog.clear()
+      },
+    },
     {
       title: "Rename session",
       value: "session.rename",
       keybind: "session_rename",
       category: "Session",
+      slash: {
+        name: "rename",
+      },
       onSelect: (dialog) => {
         dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
       },
@@ -335,6 +337,9 @@ export function Session() {
       value: "session.timeline",
       keybind: "session_timeline",
       category: "Session",
+      slash: {
+        name: "timeline",
+      },
       onSelect: (dialog) => {
         dialog.replace(() => (
           <DialogTimeline
@@ -355,6 +360,9 @@ export function Session() {
       value: "session.fork",
       keybind: "session_fork",
       category: "Session",
+      slash: {
+        name: "fork",
+      },
       onSelect: (dialog) => {
         dialog.replace(() => (
           <DialogForkFromTimeline
@@ -374,6 +382,10 @@ export function Session() {
       value: "session.compact",
       keybind: "session_compact",
       category: "Session",
+      slash: {
+        name: "compact",
+        aliases: ["summarize"],
+      },
       onSelect: (dialog) => {
         const selectedModel = local.model.current()
         if (!selectedModel) {
@@ -396,8 +408,11 @@ export function Session() {
       title: "Unshare session",
       value: "session.unshare",
       keybind: "session_unshare",
-      disabled: !session()?.share?.url,
       category: "Session",
+      enabled: !!session()?.share?.url,
+      slash: {
+        name: "unshare",
+      },
       onSelect: async (dialog) => {
         await sdk.client.session
           .unshare({
@@ -413,6 +428,9 @@ export function Session() {
       value: "session.undo",
       keybind: "messages_undo",
       category: "Session",
+      slash: {
+        name: "undo",
+      },
       onSelect: async (dialog) => {
         const status = sync.data.session_status?.[route.sessionID]
         if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
@@ -447,8 +465,11 @@ export function Session() {
       title: "Redo",
       value: "session.redo",
       keybind: "messages_redo",
-      disabled: !session()?.revert?.messageID,
       category: "Session",
+      enabled: !!session()?.revert?.messageID,
+      slash: {
+        name: "redo",
+      },
       onSelect: (dialog) => {
         dialog.clear()
         const messageID = session()?.revert?.messageID
@@ -495,6 +516,10 @@ export function Session() {
       title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
       value: "session.toggle.timestamps",
       category: "Session",
+      slash: {
+        name: "timestamps",
+        aliases: ["toggle-timestamps"],
+      },
       onSelect: (dialog) => {
         setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
         dialog.clear()
@@ -504,6 +529,10 @@ export function Session() {
       title: showThinking() ? "Hide thinking" : "Show thinking",
       value: "session.toggle.thinking",
       category: "Session",
+      slash: {
+        name: "thinking",
+        aliases: ["toggle-thinking"],
+      },
       onSelect: (dialog) => {
         setShowThinking((prev) => !prev)
         dialog.clear()
@@ -513,6 +542,9 @@ export function Session() {
       title: "Toggle diff wrapping",
       value: "session.toggle.diffwrap",
       category: "Session",
+      slash: {
+        name: "diffwrap",
+      },
       onSelect: (dialog) => {
         setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
         dialog.clear()
@@ -552,7 +584,7 @@ export function Session() {
       value: "session.page.up",
       keybind: "messages_page_up",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         scroll.scrollBy(-scroll.height / 2)
         dialog.clear()
@@ -563,7 +595,7 @@ export function Session() {
       value: "session.page.down",
       keybind: "messages_page_down",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         scroll.scrollBy(scroll.height / 2)
         dialog.clear()
@@ -574,7 +606,7 @@ export function Session() {
       value: "session.half.page.up",
       keybind: "messages_half_page_up",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         scroll.scrollBy(-scroll.height / 4)
         dialog.clear()
@@ -585,7 +617,7 @@ export function Session() {
       value: "session.half.page.down",
       keybind: "messages_half_page_down",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         scroll.scrollBy(scroll.height / 4)
         dialog.clear()
@@ -596,7 +628,7 @@ export function Session() {
       value: "session.first",
       keybind: "messages_first",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         scroll.scrollTo(0)
         dialog.clear()
@@ -607,7 +639,7 @@ export function Session() {
       value: "session.last",
       keybind: "messages_last",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         scroll.scrollTo(scroll.scrollHeight)
         dialog.clear()
@@ -618,6 +650,7 @@ export function Session() {
       value: "session.messages_last_user",
       keybind: "messages_last_user",
       category: "Session",
+      hidden: true,
       onSelect: () => {
         const messages = sync.data.message[route.sessionID]
         if (!messages || !messages.length) return
@@ -649,7 +682,7 @@ export function Session() {
       value: "session.message.next",
       keybind: "messages_next",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => scrollToMessage("next", dialog),
     },
     {
@@ -657,7 +690,7 @@ export function Session() {
       value: "session.message.previous",
       keybind: "messages_previous",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => scrollToMessage("prev", dialog),
     },
     {
@@ -706,8 +739,10 @@ export function Session() {
     {
       title: "Copy session transcript",
       value: "session.copy",
-      keybind: "session_copy",
       category: "Session",
+      slash: {
+        name: "copy",
+      },
       onSelect: async (dialog) => {
         try {
           const sessionData = session()
@@ -735,6 +770,9 @@ export function Session() {
       value: "session.export",
       keybind: "session_export",
       category: "Session",
+      slash: {
+        name: "export",
+      },
       onSelect: async (dialog) => {
         try {
           const sessionData = session()
@@ -793,7 +831,7 @@ export function Session() {
       value: "session.child.next",
       keybind: "session_child_cycle",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         moveChild(1)
         dialog.clear()
@@ -804,7 +842,7 @@ export function Session() {
       value: "session.child.previous",
       keybind: "session_child_cycle_reverse",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         moveChild(-1)
         dialog.clear()
@@ -815,7 +853,7 @@ export function Session() {
       value: "session.parent",
       keybind: "session_parent",
       category: "Session",
-      disabled: true,
+      hidden: true,
       onSelect: (dialog) => {
         const parentID = session()?.parentID
         if (parentID) {

+ 1 - 1
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -38,7 +38,7 @@ export interface DialogSelectOption<T = any> {
   disabled?: boolean
   bg?: RGBA
   gutter?: JSX.Element
-  onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
+  onSelect?: (ctx: DialogContext) => void
 }
 
 export type DialogSelectRef<T> = {