Compare commits

...
5 Commits
7 changed files with 215 additions and 74 deletions
+1 -1
View File
@@ -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<Record<string, never>>
readonly "sidebar.footer": { readonly sessionID: string }
}
export type SlotPath = keyof SlotMap
+22 -7
View File
@@ -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) {
<Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<text
id="prompt.footer.location"
fg={locationActions.hovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
truncate
flexGrow={1}
flexShrink={1}
onMouseOver={locationActions.onMouseOver}
onMouseOut={locationActions.onMouseOut}
onMouseUp={locationActions.onMouseUp}
>
{location()}
</text>
)}
+94 -60
View File
@@ -307,6 +307,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
@@ -343,6 +344,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
let didDrag = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
createEffect(() => {
const pending = preview()
@@ -364,6 +368,29 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}
})
const release = () => {
const source = dragging()
if (!source) return
if (didDrag) suppressClick = true
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
tabs.select(source)
}
const drag = (event: MouseEvent) => {
if (!rail) return
const source = dragging()
if (!source) return
didDrag = true
const target = Math.max(
0,
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
}
return (
<box
ref={(element) => (rail = element)}
@@ -375,6 +402,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
paddingTop={1}
backgroundColor={theme.background.default}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
if (!didDrag) return
didDrag = false
queueMicrotask(() => (suppressClick = false))
}}
onMouseDrag={drag}
onMouseDragEnd={release}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
@@ -522,12 +558,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
: color
return separator ? tint(faded, pulseBackground(), 0.55) : faded
}
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
tabs.select(tab.sessionID)
}
return (
<box
height={2}
@@ -539,6 +569,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
if (!rail) return
setContextMenu({
@@ -551,26 +582,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
event.stopPropagation()
return
}
didDrag = false
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (!rail) return
const target = Math.max(
0,
Math.min(
tabs.tabs().length - 1,
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
),
)
if (target !== index() && preview()?.index !== target)
setPreview({ sessionID: tab.sessionID, index: target })
}}
onMouseDragEnd={release}
>
<TabPulse
top={-1}
@@ -677,8 +692,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
@@ -737,6 +758,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event: MouseEvent) => {
didDrag = false
setDragging(undefined)
if (event.button !== RIGHT_MOUSE_BUTTON) return
if (!rail) return
setContextMenu({ x: event.x, y: event.y })
@@ -745,6 +768,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}}
onMouseUp={(event: MouseEvent) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!newTab()) tabs.add?.()
}}
>
@@ -774,6 +798,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!addHovered()) return
event.stopPropagation()
tabs.close()
@@ -803,6 +828,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
@@ -810,6 +836,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
let strip: { screenX: number; screenY: number } | undefined
let didDrag = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
@@ -931,6 +960,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
return layout().before + layout().widths.length - 1
}
const release = () => {
const source = dragging()
if (!source) return
if (didDrag) suppressClick = true
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
if (source === NEW_SESSION_TAB.sessionID) return
tabs.select(source)
}
const drag = (event: MouseEvent) => {
const source = dragging()
if (!source || source === NEW_SESSION_TAB.sessionID) return
didDrag = true
const slot = slotAt(event.x)
const target = slot === undefined ? undefined : Math.min(slot, tabs.tabs().length - 1)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== undefined && target !== sourceIndex && preview()?.index !== target) {
setPreview({ sessionID: source, index: target })
}
}
return (
<box
ref={(element) => (strip = element)}
@@ -940,24 +992,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
flexDirection="row"
zIndex={1}
onMouseOut={marquee.leaveHovered}
renderAfter={function (buffer) {
const x = Math.max(0, this.screenX)
const y = this.screenY + this.height
const width = Math.min(this.width, buffer.width - x)
if (y < 0 || y >= buffer.height || width <= 0) return
buffer.fillRect(
x,
y,
width,
1,
RGBA.fromValues(
theme.background.default.r,
theme.background.default.g,
theme.background.default.b,
mode() === "light" ? 0.14 : 0.28,
),
)
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
if (!didDrag) return
didDrag = false
queueMicrotask(() => (suppressClick = false))
}}
onMouseDrag={drag}
onMouseDragEnd={release}
>
<Show when={layout().before > 0}>
<text width={sessionTabOverflowWidth(layout().before)} fg={theme.text.subdued} selectable={false}>
@@ -1051,15 +1094,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID)
}
return (
<box
width={width()}
@@ -1070,6 +1104,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
setContextMenu({
x: event.x,
@@ -1081,20 +1116,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
event.stopPropagation()
return
}
didDrag = false
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1)
setPreview({ sessionID: tab.sessionID, index: slot })
}}
onMouseDragEnd={release}
>
<TabPulse
enabled={animations()}
@@ -1140,8 +1165,14 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
// The close mark only renders while hovered; without motion events a click can
// land here first, and must select the tab instead of closing it invisibly.
if (hovered() !== tab.sessionID) return
@@ -1170,6 +1201,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event) => {
didDrag = false
setDragging(undefined)
if (event.button !== RIGHT_MOUSE_BUTTON) return
setContextMenu({ x: event.x, y: event.y })
event.preventDefault()
@@ -1177,6 +1210,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
tabs.add?.()
}}
>
@@ -20,7 +20,7 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("mcp.list")}>
<text fg={props.context.theme.text.default}>
<Switch>
<Match when={failed()}>
@@ -56,7 +56,7 @@ function Plugins(props: { context: Plugin.Context }) {
return (
<Show when={failed()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("plugins.list")}>
<text fg={props.context.theme.text.default}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
@@ -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 (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
{(value) => (
<box
id="sidebar.footer.location"
onMouseOver={actions.onMouseOver}
onMouseOut={actions.onMouseOut}
onMouseUp={actions.onMouseUp}
>
<FilePath
value={value()}
maxWidth={38}
fg={actions.hovered() ? props.context.theme.text.default : props.context.theme.text.subdued}
/>
</box>
)}
</Show>
)
}
@@ -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: () => <View context={context} /> })
context.ui.slot({
append: "sidebar.footer",
render: (props) => <View context={context} sessionID={props.sessionID} />,
})
},
})
+1 -1
View File
@@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<Slot path="sidebar.footer" />
<Slot path="sidebar.footer" input={{ sessionID: props.sessionID }} />
</box>
</box>
</Show>
@@ -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(() => (
<DialogSelect
title="Working directory"
renderFilter={false}
options={[
{
title: "Copy path",
value: "location.copy",
description: directory,
onSelect: (dialog) => {
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,
}
}