diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 73b515c7f06..8fd7cefd174 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -171,7 +171,7 @@ export interface SlotMap { readonly "prompt.footer.file": PromptFooterInput readonly "session.composer.top": { readonly sessionID: string } readonly "sidebar.content": { readonly sessionID: string } - readonly "sidebar.footer": Readonly> + readonly "sidebar.footer": { readonly sessionID: string } } export type SlotPath = keyof SlotMap diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index f20daa22592..770216adc8e 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -70,6 +70,7 @@ import { import { DialogImagePreview } from "../dialog-image-preview" import { useDirectoryRecents } from "../../prompt/directory-recents" import { directoryRecentValue } from "../../prompt/directory-completion" +import { useWorkingDirectoryActions } from "../../ui/working-directory-actions" export type PromptProps = { sessionID?: string @@ -1539,21 +1540,25 @@ export function Prompt(props: PromptProps) { const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5 return Locale.takeWidth(value, Math.max(1, width)).trimEnd() }) - const locationLabel = createMemo(() => { + const footerLocation = createMemo(() => { if (!props.sessionID) { // No session yet: show where the next session will be created. - const location = currentLocation.ref ?? data.location.default() - const directory = abbreviateHome(location.directory, paths.home) - const branch = data.location.vcs.info(location)?.branch.current - return branch ? `${directory}:${branch}` : directory + return currentLocation.ref ?? data.location.default() } if (status() !== "idle") return - const location = data.session.get(props.sessionID)?.location + return data.session.get(props.sessionID)?.location + }) + const locationLabel = createMemo(() => { + const location = footerLocation() if (!location) return const directory = abbreviateHome(location.directory, paths.home) const branch = data.location.vcs.info(location)?.branch.current return branch ? `${directory}:${branch}` : directory }) + const locationActions = useWorkingDirectoryActions({ + directory: () => footerLocation()?.directory, + onMove: () => void move.open(), + }) const spinnerDef = createMemo(() => { const agent = status() === "running" ? local.agent.current() : local.agent.current() @@ -1874,7 +1879,17 @@ export function Prompt(props: PromptProps) { }> {(location) => ( - + {location()} )} diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index e1a913f864c..098b5897f48 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -1,8 +1,18 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Show } from "solid-js" import { FilePath } from "../../ui/file-path" +import { useWorkingDirectoryActions } from "../../ui/working-directory-actions" +import { usePromptMove } from "../../component/prompt/move" -function View(props: { context: Plugin.Context }) { +function View(props: { context: Plugin.Context; sessionID: string }) { + const move = usePromptMove({ + projectID: () => props.context.data.session.get(props.sessionID)?.projectID, + sessionID: () => props.sessionID, + }) + const actions = useWorkingDirectoryActions({ + directory: () => props.context.location?.directory, + onMove: () => void move.open(), + }) const directory = createMemo(() => { if (!props.context.location) return undefined const value = props.context.ui.format.path(props.context.location.directory) @@ -11,7 +21,20 @@ function View(props: { context: Plugin.Context }) { }) return ( - {(value) => } + {(value) => ( + + + + )} ) } @@ -21,6 +44,9 @@ export default Plugin.define({ setup(context) { // Append keeps the path open to additive plugin claims; an external // replace still takes the boundary over. - context.ui.slot({ append: "sidebar.footer", render: () => }) + context.ui.slot({ + append: "sidebar.footer", + render: (props) => , + }) }, }) diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 6edd76f4a35..c48b42ebc4d 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - + diff --git a/packages/tui/src/ui/working-directory-actions.tsx b/packages/tui/src/ui/working-directory-actions.tsx new file mode 100644 index 00000000000..8668a37c19e --- /dev/null +++ b/packages/tui/src/ui/working-directory-actions.tsx @@ -0,0 +1,66 @@ +import { createSignal } from "solid-js" +import open from "open" +import { useRenderer } from "@opentui/solid" +import { useClipboard } from "../context/clipboard" +import { useDialog } from "./dialog" +import { DialogSelect } from "./dialog-select" +import { useToast } from "./toast" + +export function useWorkingDirectoryActions(input: { directory: () => string | undefined; onMove?: () => void }) { + const clipboard = useClipboard() + const dialog = useDialog() + const renderer = useRenderer() + const toast = useToast() + const [hovered, setHovered] = createSignal(false) + + function openMenu() { + if (renderer.getSelection()?.getSelectedText()) return + const directory = input.directory() + if (!directory) return + dialog.replace(() => ( + { + void clipboard.write(directory).then(() => { + dialog.clear() + toast.show({ message: "Path copied to clipboard", variant: "info" }) + }, toast.error) + }, + }, + { + title: "Open folder", + value: "location.open", + description: "in system file manager", + onSelect: (dialog) => { + dialog.clear() + void open(directory).catch(toast.error) + }, + }, + ...(input.onMove + ? [ + { + title: "Move session", + value: "session.move", + description: "to another working directory", + onSelect: () => void input.onMove?.(), + }, + ] + : []), + ]} + /> + )) + } + + return { + hovered, + onMouseOver: () => setHovered(true), + onMouseOut: () => setHovered(false), + onMouseUp: openMenu, + } +}