mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb7fcc49a | |||
| c49f71185d |
@@ -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 sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# TUI UI Experiments
|
||||
|
||||
- Before implementing a visual behavior as an experiment, add a fixture-driven story under `src/feature-plugins/system/storybook` that renders the real production component.
|
||||
- Put the current treatment and meaningfully different variants in the story. Expose replay and tuning controls in `StoryFooter`, including reset when values are adjustable.
|
||||
- Let the user choose or tune a variant in the story before selecting production defaults.
|
||||
- After selection, register the behavior in `src/component/dialog-experiments.tsx` and gate it with `config.data.experimental?.<id> === true`; experiments must not change default behavior.
|
||||
- Treat tuning-only stories as local scaffolding and remove them and their registration before committing. Commit a story only when the user explicitly wants it retained as a reusable regression fixture.
|
||||
- Use OpenCode Drive with a simulated LLM for deterministic turn/session behavior. Do not invoke a real model only to verify TUI behavior.
|
||||
- Run the story with `OPENCODE_STORY=<story-id> bun run dev:live` and exercise relevant wide and narrow terminal sizes.
|
||||
@@ -722,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "open.menu",
|
||||
title: "Open session or worktree",
|
||||
title: "Open session or project",
|
||||
category: "Session",
|
||||
slash: { name: "open", aliases: ["projects", "project"] },
|
||||
run: async () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, on, onMount, Show } from "solid-js"
|
||||
import { tint } from "../theme/color"
|
||||
import { createAnimatable, tween } from "../ui/animation"
|
||||
|
||||
export type AssistantSummaryFlash = {
|
||||
trigger: number
|
||||
duration: number
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export function AssistantSummary(props: {
|
||||
agent: string
|
||||
model: string
|
||||
duration?: string
|
||||
interrupted?: boolean
|
||||
agentColor: RGBA
|
||||
subduedColor: RGBA
|
||||
flashColor: RGBA
|
||||
animations: boolean
|
||||
flash?: AssistantSummaryFlash
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const flash = createAnimatable(
|
||||
{ level: 0 },
|
||||
{
|
||||
enabled: () => props.animations,
|
||||
transition: tween({ duration: props.flash?.duration ?? 0.32 }),
|
||||
},
|
||||
)
|
||||
const run = () => {
|
||||
if (!props.flash || !props.animations || props.flash.trigger === 0) return
|
||||
flash.jump({ level: props.flash.intensity })
|
||||
flash.animate({ level: 0 })
|
||||
}
|
||||
onMount(run)
|
||||
createEffect(on(() => props.flash?.trigger, run, { defer: true }))
|
||||
const color = (resting: RGBA) => tint(resting, props.flashColor, flash.value().level)
|
||||
|
||||
return (
|
||||
<text>
|
||||
<span style={{ fg: color(props.agentColor) }}>{props.agent}</span>
|
||||
<Show when={dimensions().width >= 28}>
|
||||
<span style={{ fg: color(props.subduedColor) }}> · {props.model}</span>
|
||||
</Show>
|
||||
<Show when={props.duration && (dimensions().width < 28 || dimensions().width >= 36)}>
|
||||
<span style={{ fg: color(props.subduedColor) }}> · {props.duration}</span>
|
||||
</Show>
|
||||
<Show when={props.interrupted}>
|
||||
<span style={{ fg: color(props.subduedColor) }}> · interrupted</span>
|
||||
</Show>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,11 @@ export const experiments: Experiment[] = [
|
||||
title: "Remember tab scroll",
|
||||
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
|
||||
},
|
||||
{
|
||||
id: "turn_summary_flash",
|
||||
title: "Turn summary flash",
|
||||
description: "Brighten the agent, model, and duration when a turn completes, then fade to their resting colors.",
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogExperiments() {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { WorktreeListOutput } from "@opencode-ai/client"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogWorktreeName } from "./dialog-worktree-name"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
|
||||
export type MoveSessionSelection =
|
||||
| { type: "directory"; directory: string; subdirectory: boolean }
|
||||
@@ -28,9 +27,6 @@ type ProjectDirectory = WorktreeListOutput[number]
|
||||
|
||||
type DialogMoveSessionProps = {
|
||||
projectID: string
|
||||
title?: string
|
||||
compact?: boolean
|
||||
randomWorktree?: boolean
|
||||
current?: MoveSessionSelection
|
||||
onSelect: (selection: MoveSessionSelection) => void
|
||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||
@@ -55,8 +51,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const randomWorktree = Slug.create()
|
||||
onMount(() => dialog.setSize(props.compact ? "large" : "xlarge"))
|
||||
onMount(() => dialog.setSize("xlarge"))
|
||||
|
||||
function reopen(initialRemoving?: string) {
|
||||
dialog.replace(() => (
|
||||
@@ -127,6 +122,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
|
||||
return 0
|
||||
})
|
||||
if (roots.length === 0) return []
|
||||
|
||||
const subdirectories = sessionData.session
|
||||
.list()
|
||||
.filter(
|
||||
@@ -153,7 +150,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
})
|
||||
const titleWidth = Math.max(1, dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)))
|
||||
|
||||
const options: DialogSelectOption<MoveSessionSelection | undefined>[] = list.map((item) => {
|
||||
return list.map((item) => {
|
||||
const title = abbreviateHome(item.location, paths.home)
|
||||
const suffix =
|
||||
item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
|
||||
@@ -186,19 +183,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
truncateTitle: "left" as const,
|
||||
}
|
||||
})
|
||||
if (props.randomWorktree) {
|
||||
return [
|
||||
{
|
||||
title: "+ New random worktree",
|
||||
footer: randomWorktree,
|
||||
value: { type: "new", name: randomWorktree },
|
||||
category: "Create",
|
||||
titleWidth,
|
||||
},
|
||||
...options,
|
||||
]
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
@@ -316,11 +300,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return (
|
||||
<box minHeight={showError() ? 5 : fullHeight()}>
|
||||
<DialogSelect
|
||||
title={props.title ?? "Move session"}
|
||||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
{props.title ?? "Move session"}
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
<Spinner />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import path from "path"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
@@ -20,17 +19,11 @@ import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
import { projectName } from "../util/project"
|
||||
import { DialogMoveSession } from "./dialog-move-session"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
const RECENT_LIMIT = 3
|
||||
const RECENT_LIMIT = 8
|
||||
export const DialogOpenKey = Symbol("DialogOpen")
|
||||
|
||||
type OpenTarget =
|
||||
| { type: "session"; sessionID: string }
|
||||
| { type: "location"; directory: string; projectID?: string; vcs?: "git" | "hg" }
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
|
||||
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
|
||||
const [, sessions] = await Promise.all([
|
||||
@@ -56,10 +49,8 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const paths = useTuiPaths()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
const [selected, setSelected] = createSignal<OpenTarget>()
|
||||
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
@@ -93,12 +84,14 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const tabs = openTabs()
|
||||
const exact = matched()
|
||||
const recent = sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.concat(exact && !tabs.has(exact.id) ? [exact] : [])
|
||||
.filter((session, index, items) => items.findIndex((item) => item.id === session.id) === index)
|
||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
||||
// matching a loaded tab by name still switches to it.
|
||||
const recent = filter().trim()
|
||||
? sessions()
|
||||
: sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = projectName(project)
|
||||
@@ -109,7 +102,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
title: withTimestampedFallback(session),
|
||||
searchText: session.id,
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Recent sessions",
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
@@ -120,171 +113,47 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
}
|
||||
})
|
||||
|
||||
const current = location.current
|
||||
const locations = new Map<
|
||||
string,
|
||||
{
|
||||
directory: string
|
||||
title: string
|
||||
updated: number
|
||||
category: "Recent worktrees" | "Recent folders"
|
||||
projectID?: string
|
||||
vcs?: "git" | "hg"
|
||||
}
|
||||
>()
|
||||
for (const project of data.project.list()) {
|
||||
if (project.canonical === "/" || isDisposableLocation(project.canonical) || locations.has(project.canonical))
|
||||
continue
|
||||
locations.set(project.canonical, {
|
||||
directory: project.canonical,
|
||||
title: projectName(project) ?? project.canonical,
|
||||
updated: project.time.updated,
|
||||
category: "Recent worktrees",
|
||||
projectID: project.id,
|
||||
vcs: project.vcs,
|
||||
const current = location.current?.project
|
||||
const seen = new Set<string>()
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
})
|
||||
}
|
||||
for (const session of sessions()) {
|
||||
const project = data.project.get(session.projectID)
|
||||
const managedProject = project && project.canonical !== "/" ? project : undefined
|
||||
const worktree = managedProject && !session.subpath
|
||||
const directory = worktree ? session.location.directory : (managedProject?.canonical ?? session.location.directory)
|
||||
if (isDisposableLocation(directory)) continue
|
||||
const existing = locations.get(directory)
|
||||
if (existing) {
|
||||
existing.updated = Math.max(existing.updated, session.time.updated)
|
||||
continue
|
||||
}
|
||||
locations.set(directory, {
|
||||
directory,
|
||||
title:
|
||||
worktree && directory !== managedProject.canonical
|
||||
? [projectName(managedProject), path.basename(directory)].filter(Boolean).join(" · ")
|
||||
: (projectName(project) ?? (path.basename(directory) || directory)),
|
||||
updated: session.time.updated,
|
||||
category: managedProject ? "Recent worktrees" : "Recent folders",
|
||||
projectID: managedProject?.id,
|
||||
vcs: managedProject?.vcs,
|
||||
})
|
||||
}
|
||||
const locationOptions = [...locations.values()]
|
||||
.toSorted((a, b) => b.updated - a.updated)
|
||||
.map((item) => {
|
||||
const footer = abbreviateHome(item.directory, paths.home)
|
||||
.map((project) => {
|
||||
const title = projectName(project) ?? project.canonical
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(item.title)
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
return {
|
||||
title: item.title,
|
||||
title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
value: {
|
||||
type: "location",
|
||||
directory: item.directory,
|
||||
projectID: item.projectID,
|
||||
vcs: item.vcs,
|
||||
} as OpenTarget,
|
||||
category: item.category,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
gutter:
|
||||
item.directory === current?.directory || item.directory === current?.project.canonical
|
||||
project.canonical === current?.canonical
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
...sessionOptions,
|
||||
...locationOptions.filter((item) => item.category === "Recent worktrees"),
|
||||
...locationOptions.filter((item) => item.category === "Recent folders"),
|
||||
]
|
||||
return [...sessionOptions, ...projectOptions]
|
||||
})
|
||||
|
||||
function openLocation(directory: string) {
|
||||
dialog.clear()
|
||||
const target = { directory }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}
|
||||
|
||||
function openWorktrees(target: OpenTarget | undefined) {
|
||||
if (target?.type !== "location" || !target.projectID || target.vcs !== "git") return
|
||||
const projectID = target.projectID
|
||||
dialog.replace(() => (
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
title="Open worktree"
|
||||
compact={true}
|
||||
randomWorktree={true}
|
||||
onSelect={(selection) => {
|
||||
if (selection.type === "directory") {
|
||||
openLocation(selection.directory)
|
||||
return
|
||||
}
|
||||
void client.api.worktree
|
||||
.create({
|
||||
projectID,
|
||||
strategy: "git",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
name: selection.name,
|
||||
})
|
||||
.then((result) => openLocation(result.directory))
|
||||
.catch((error) =>
|
||||
toast.show({ variant: "error", title: "Creating worktree failed", message: errorMessage(error) }),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function browse() {
|
||||
dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Open folder"
|
||||
placeholder="Absolute path"
|
||||
value={location.current?.directory ?? paths.home}
|
||||
onConfirm={(value) => {
|
||||
const directory = value.trim().replace(/^~(?=$|[\\/])/, paths.home)
|
||||
if (!directory) return
|
||||
void client.api.file
|
||||
.list({ location: { directory } })
|
||||
.then(() => openLocation(directory))
|
||||
.catch((error) =>
|
||||
toast.show({ variant: "error", title: "Could not open folder", message: errorMessage(error) }),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Open"
|
||||
placeholder="Search sessions and worktrees…"
|
||||
placeholder="Search sessions and projects…"
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
sectionNavigation={true}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={(option) => {
|
||||
setSelectionMoved(true)
|
||||
setSelected(option.value)
|
||||
}}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
footer={
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>open</span>
|
||||
{" "}→ <span style={{ fg: theme.text.subdued }}>worktrees</span>
|
||||
{" "}/ <span style={{ fg: theme.text.subdued }}>browse</span>
|
||||
</text>
|
||||
}
|
||||
bindings={[
|
||||
{
|
||||
bind: "right",
|
||||
title: "Open worktrees",
|
||||
group: "Dialog",
|
||||
run: () => openWorktrees(selected() ?? options()[0]?.value),
|
||||
},
|
||||
{ bind: "/", title: "Browse folders", group: "Dialog", run: browse },
|
||||
]}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
@@ -300,7 +169,9 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||
return
|
||||
}
|
||||
openLocation(option.value.directory)
|
||||
const target = { directory: option.value.directory }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -318,7 +189,3 @@ function timeAgo(timestamp: number) {
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${Math.floor(days / 365)}y`
|
||||
}
|
||||
|
||||
function isDisposableLocation(directory: string) {
|
||||
return /^opencode-(?:test|e2e-project)-/.test(path.basename(directory))
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ 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
|
||||
@@ -1540,25 +1539,21 @@ 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 footerLocation = createMemo(() => {
|
||||
const locationLabel = createMemo(() => {
|
||||
if (!props.sessionID) {
|
||||
// No session yet: show where the next session will be created.
|
||||
return currentLocation.ref ?? data.location.default()
|
||||
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
|
||||
}
|
||||
if (status() !== "idle") return
|
||||
return data.session.get(props.sessionID)?.location
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
const location = footerLocation()
|
||||
const location = data.session.get(props.sessionID)?.location
|
||||
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()
|
||||
@@ -1879,17 +1874,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<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}
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Definitions = {
|
||||
"session.move": keybind("none", "Move session"),
|
||||
"session.new": keybind("<leader>n", "Create a new session"),
|
||||
"session.list": keybind("<leader>l", "List all sessions"),
|
||||
"open.menu": keybind("ctrl+o", "Open recent sessions and worktrees"),
|
||||
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
|
||||
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
|
||||
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
|
||||
"session.tab.history.back": keybind("none", "Go back in session tab history"),
|
||||
|
||||
@@ -20,7 +20,7 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
|
||||
return (
|
||||
<Show when={list().length}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0} onMouseUp={() => props.context.keymap.dispatch("mcp.list")}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<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} onMouseUp={() => props.context.keymap.dispatch("plugins.list")}>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
<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,18 +1,8 @@
|
||||
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; 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(),
|
||||
})
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const directory = createMemo(() => {
|
||||
if (!props.context.location) return undefined
|
||||
const value = props.context.ui.format.path(props.context.location.directory)
|
||||
@@ -21,20 +11,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
})
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
{(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>
|
||||
)}
|
||||
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -44,9 +21,6 @@ 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: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
type SessionRow,
|
||||
} from "./rows"
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { AssistantSummary } from "../../component/assistant-summary"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
@@ -1334,7 +1335,7 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
<Show when={props.message(row().messageID)}>
|
||||
{(message) => (
|
||||
<Show when={message().type === "assistant"}>
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} />
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} flash={row().flash} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
@@ -1794,11 +1795,10 @@ function SessionGroupView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant; flash?: true }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
@@ -1818,20 +1818,21 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<box paddingLeft={3} marginTop={props.message.retry || (props.message.error && !interrupted()) ? 1 : 0}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<Show when={dimensions().width >= 28}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
|
||||
</Show>
|
||||
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
<Show when={interrupted()}>
|
||||
<span style={{ fg: theme.text.subdued }}> · interrupted</span>
|
||||
</Show>
|
||||
</text>
|
||||
<AssistantSummary
|
||||
agent={Locale.titlecase(props.message.agent)}
|
||||
model={model()}
|
||||
duration={duration() ? Locale.duration(duration()) : undefined}
|
||||
interrupted={interrupted()}
|
||||
agentColor={props.message.error ? theme.text.subdued : local.agent.color(props.message.agent)}
|
||||
subduedColor={theme.text.subdued}
|
||||
flashColor={theme.text.default}
|
||||
animations={ctx.config.animations ?? true}
|
||||
flash={
|
||||
props.flash && ctx.config.experimental?.turn_summary_flash === true
|
||||
? { trigger: 1, duration: 0.8, intensity: 0.7 }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ export type SessionRow =
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "assistant-footer"; messageID: string; flash?: true }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
@@ -176,13 +176,13 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
}),
|
||||
)
|
||||
|
||||
const appendFooter = (messageID: string) =>
|
||||
const appendFooter = (messageID: string, flash?: true) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
|
||||
const index = queuedStart(draft)
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "assistant-footer", messageID })
|
||||
draft.splice(index, 0, { type: "assistant-footer", messageID, ...(flash ? { flash } : {}) })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -268,12 +268,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
}),
|
||||
data.on("session.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
appendFooter(event.data.assistantMessageID, true)
|
||||
if (turnTokens()) setRows(reconcile(reduce()))
|
||||
}),
|
||||
data.on("session.step.failed", (event) => {
|
||||
if (event.data.sessionID !== sessionID()) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
appendFooter(event.data.assistantMessageID, true)
|
||||
if (turnTokens()) setRows(reconcile(reduce()))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<Slot path="sidebar.footer" input={{ sessionID: props.sessionID }} />
|
||||
<Slot path="sidebar.footer" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
await fixture.app.mockInput.typeText(sessionID)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
|
||||
|
||||
@@ -158,7 +158,7 @@ test("waits for sessions before showing the populated picker", async () => {
|
||||
|
||||
try {
|
||||
await fixture.app.renderOnce()
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and worktrees")
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
|
||||
|
||||
resolveSessions(
|
||||
json({
|
||||
@@ -276,148 +276,6 @@ test("option arrows stay in the only visible section", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("search keeps sessions limited to recents", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: Array.from({ length: 9 }, (_, index) => ({
|
||||
id: `ses_${index}`,
|
||||
projectID: `proj_${index}`,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 10 - index, updated: 10 - index },
|
||||
title: index === 8 ? "Ancient hidden session" : `Recent session ${index}`,
|
||||
location: { directory: `/tmp/location-${index}` },
|
||||
})),
|
||||
cursor: {},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent sessions"))
|
||||
await fixture.app.mockInput.typeText("Ancient hidden session")
|
||||
await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("No matches") && frame.split("Ancient hidden session").length === 2,
|
||||
)
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("opens worktrees with right and creates a random worktree", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: "/tmp/opencode",
|
||||
vcs: "git",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("Recent worktrees"))
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open worktree") && frame.includes("New random worktree"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/created" } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("opens the folder prompt with slash", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
return undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
|
||||
await fixture.app.mockInput.typeText("/")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open folder") && frame.includes("/tmp/opencode/home"))
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("hides disposable test projects from recent locations", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_real",
|
||||
canonical: "/workspace/opencode",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: "/tmp/opencode-e2e-project-BX8Aug",
|
||||
time: { created: 1, updated: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode"))
|
||||
expect(frame).not.toContain("opencode-e2e-project-BX8Aug")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows a session checkout as a recent worktree", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_worktree",
|
||||
projectID: "proj_opencode",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 2, updated: 3 },
|
||||
title: "Refine Open screen",
|
||||
location: { directory: "/workspace/worktrees/open-screen" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_opencode",
|
||||
canonical: "/workspace/opencode",
|
||||
vcs: "git",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Recent worktrees") && value.includes("OpenCode · open-screen"),
|
||||
)
|
||||
expect(frame).toContain("/workspace/worktrees/open-screen")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderOpen(
|
||||
handler: FetchHandler,
|
||||
beforeOpen?: (contexts: {
|
||||
|
||||
Reference in New Issue
Block a user