mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 15:03:43 -04:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5121e0251 | |||
| 48b3635dfe | |||
| 2cf20e660e | |||
| 53c94759c5 | |||
| 0a25525064 | |||
| f80639214e | |||
| fb8b4c4ce6 | |||
| d76195fe3b | |||
| 1b587823b6 | |||
| ed70241753 | |||
| c7de57ee0e | |||
| 20ddb570cc | |||
| 642772e2a5 | |||
| 4a7444ecee | |||
| 6bafc34408 | |||
| 595e4c8c96 | |||
| 20929b3081 | |||
| 8bcc245142 |
@@ -48,20 +48,12 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const dropLabel = createMemo(() => {
|
||||
const input = props.controller.model.selection.current()?.capabilities.input
|
||||
if (!input?.image && !input?.pdf) return language.t("ui.promptInput.dropFiles")
|
||||
if (!input.pdf) return language.t("ui.promptInput.dropFiles.image")
|
||||
if (!input.image) return language.t("ui.promptInput.dropFiles.pdf")
|
||||
return language.t("ui.promptInput.dropFiles.imagePdf")
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
controller={props.controller}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
dropLabel={dropLabel()}
|
||||
class={props.class}
|
||||
variantControlVisible={!props.controller.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
|
||||
@@ -122,7 +122,10 @@ const layer = Layer.effect(
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
const agents = Array.fromIterable(state.get().agents.values())
|
||||
const selected = selectedDefault()
|
||||
if (!selected) return agents
|
||||
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
@@ -22,38 +22,64 @@ export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
||||
|
||||
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||
|
||||
export const ripgrepLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const files: string[] = []
|
||||
const directories = new Set<string>()
|
||||
const clock = yield* Clock.Clock
|
||||
const home = Protected.isHome(location.directory)
|
||||
yield* ripgrep
|
||||
.find({
|
||||
let index = { files: [] as string[], directories: new Set<string>() }
|
||||
let initialized = false
|
||||
let settledAt = Number.NEGATIVE_INFINITY
|
||||
let refreshing = false
|
||||
const scan = Effect.gen(function* () {
|
||||
const next = { files: [] as string[], directories: new Set<string>() }
|
||||
if (!initialized) index = next
|
||||
yield* ripgrep.find({
|
||||
cwd: location.directory,
|
||||
pattern: "*",
|
||||
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
files.push(entry.path)
|
||||
next.files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||
parts
|
||||
.slice(0, -1)
|
||||
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||
index = next
|
||||
initialized = true
|
||||
}).pipe(
|
||||
Effect.orDie,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
settledAt = clock.currentTimeMillisUnsafe()
|
||||
refreshing = false
|
||||
}),
|
||||
),
|
||||
)
|
||||
const refresh = Effect.sync(() => {
|
||||
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
|
||||
refreshing = true
|
||||
return scan
|
||||
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
|
||||
yield* refresh
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* refresh
|
||||
const items =
|
||||
input.type === "file"
|
||||
? files
|
||||
? index.files
|
||||
: input.type === "directory"
|
||||
? Array.from(directories)
|
||||
: [...files, ...directories]
|
||||
? Array.from(index.directories)
|
||||
: [...index.files, ...index.directories]
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
|
||||
@@ -68,6 +68,26 @@ describe("Agent", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists the selected default agent first", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* agent.transform((editor) => {
|
||||
editor.update(Agent.ID.make("build"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("reviewer"), (info) => {
|
||||
info.mode = "primary"
|
||||
})
|
||||
editor.update(Agent.ID.make("explore"), (info) => {
|
||||
info.mode = "subagent"
|
||||
})
|
||||
editor.default(Agent.ID.make("reviewer"))
|
||||
})
|
||||
|
||||
expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds state when a transform is replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
||||
@@ -56,4 +57,70 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
let scans = 0
|
||||
const initial = Effect.runSync(Deferred.make<void>())
|
||||
const started = Effect.runSync(Deferred.make<void>())
|
||||
const release = Effect.runSync(Deferred.make<void>())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans === 1) yield* Deferred.succeed(initial, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Deferred.await(initial)
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
expect(scans).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* search.find({ query: "old", type: "file" })
|
||||
yield* Deferred.await(started)
|
||||
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
expect(scans).toBe(2)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const refreshed = yield* Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
return yield* search.find({ query: "new", type: "file" })
|
||||
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
|
||||
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
|
||||
expect(scans).toBe(2)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,7 +39,6 @@ export type PromptInputV2Props = {
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
borderUnderlay?: boolean
|
||||
dropLabel?: string
|
||||
class?: string
|
||||
modelControl?: JSX.Element
|
||||
variantControlVisible?: boolean
|
||||
@@ -110,10 +109,11 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
</Show>
|
||||
<form
|
||||
data-component="prompt-input-v2"
|
||||
data-dock-border-underlay={props.borderUnderlay && state.drag !== "active" ? "v2" : undefined}
|
||||
data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
|
||||
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay && state.drag !== "active",
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -125,22 +125,8 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
onDrop={props.controller.onDrop}
|
||||
>
|
||||
<Show when={state.drag === "active"}>
|
||||
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-[color-mix(in_srgb,var(--v2-background-bg-accent)_5%,var(--v2-background-bg-base))] text-v2-text-text-muted">
|
||||
<svg class="absolute inset-0 size-full" aria-hidden="true">
|
||||
<rect
|
||||
x="0.25"
|
||||
y="0.25"
|
||||
width="calc(100% - 0.5px)"
|
||||
height="calc(100% - 0.5px)"
|
||||
rx="11.75"
|
||||
fill="none"
|
||||
stroke="var(--v2-border-border-focus)"
|
||||
stroke-width="0.5"
|
||||
stroke-dasharray="3 3"
|
||||
stroke-dashoffset="1.5"
|
||||
/>
|
||||
</svg>
|
||||
{props.dropLabel ?? i18n.t("ui.promptInput.dropFiles")}
|
||||
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
|
||||
{i18n.t("ui.promptInput.dropFiles")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -115,9 +115,10 @@ export function elements(renderer: CliRenderer): Element[] {
|
||||
}
|
||||
|
||||
export function state(harness: Harness) {
|
||||
const renderable = harness.renderer.currentFocusedRenderable?.num
|
||||
return {
|
||||
focused: {
|
||||
renderable: harness.renderer.currentFocusedRenderable?.num,
|
||||
...(renderable === undefined ? {} : { renderable }),
|
||||
editor: Boolean(harness.renderer.currentFocusedEditor),
|
||||
},
|
||||
elements: elements(harness.renderer),
|
||||
|
||||
@@ -14,6 +14,18 @@ test("matches literal screen text", () => {
|
||||
expect(matches(harness, "opencode")).toBe(false)
|
||||
})
|
||||
|
||||
test("omits an absent focused renderable from state", () => {
|
||||
const harness = {
|
||||
renderer: {
|
||||
root: { getChildren: () => [] },
|
||||
currentFocusedRenderable: undefined,
|
||||
currentFocusedEditor: undefined,
|
||||
},
|
||||
} as unknown as Harness
|
||||
|
||||
expect(state(harness)).toEqual({ focused: { editor: false }, elements: [] })
|
||||
})
|
||||
|
||||
test("normalizes named keys for OpenTUI", async () => {
|
||||
const pressed: Array<readonly [string, object | undefined]> = []
|
||||
const harness = {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Experiment = {
|
||||
id: "tab_drafts"
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
@@ -12,36 +13,30 @@ type Experiment = {
|
||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||
// experiment either graduates (delete the entry, make the behavior
|
||||
// unconditional) or dies (delete the entry and the branch it gated).
|
||||
export const experiments: Experiment[] = [
|
||||
{
|
||||
id: "tab_drafts",
|
||||
title: "Per-tab prompt drafts",
|
||||
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
|
||||
},
|
||||
]
|
||||
export const experiments: Experiment[] = []
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [selected, setSelected] = createSignal<Experiment>()
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
||||
|
||||
const options = createMemo(() =>
|
||||
experiments.map((experiment, index) => ({
|
||||
experiments.map((experiment) => ({
|
||||
title: experiment.title,
|
||||
category: "Experiments",
|
||||
searchText: experiment.description,
|
||||
footer: enabled(experiment) ? "on" : "off",
|
||||
value: index,
|
||||
value: experiment,
|
||||
})),
|
||||
)
|
||||
|
||||
// All experiments are booleans, so either direction toggles.
|
||||
async function change(index = selected()) {
|
||||
async function change(experiment = selected()) {
|
||||
if (saving()) return
|
||||
const experiment = experiments[index]
|
||||
if (!experiment) return
|
||||
const next = !enabled(experiment)
|
||||
setSaving(true)
|
||||
@@ -58,23 +53,33 @@ export function DialogExperiments() {
|
||||
<DialogSelect
|
||||
title="Experiments"
|
||||
options={options()}
|
||||
renderFilter={experiments.length > 0}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(option.value)}
|
||||
footerHints={[{ title: "←/→", label: "change" }]}
|
||||
bindings={[
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No experiments available</text>
|
||||
</box>
|
||||
}
|
||||
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
|
||||
bindings={
|
||||
experiments.length > 0
|
||||
? [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type DialogMoveSessionProps = {
|
||||
onSelect: (selection: MoveSessionSelection) => void
|
||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||
initialDirectories?: ReadonlyArray<ProjectDirectory>
|
||||
fixture?: boolean
|
||||
initialRemoving?: string
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
})
|
||||
|
||||
const [directories, { refetch }] = createResource(
|
||||
() => (props.initialRemoving ? undefined : props.projectID),
|
||||
() => (props.fixture || props.initialRemoving ? undefined : props.projectID),
|
||||
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
|
||||
try {
|
||||
const requestLocation = { directory: location()?.directory || paths.cwd }
|
||||
@@ -110,11 +111,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (showError()) return
|
||||
const directory = currentDirectory()
|
||||
if (!directory) return
|
||||
return (
|
||||
directoryData()
|
||||
?.filter((root) => contains(root.directory, directory))
|
||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory }
|
||||
)
|
||||
return directoryData()
|
||||
?.filter((root) => contains(root.directory, directory))
|
||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0]
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
||||
@@ -123,7 +122,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const current = currentRoot()?.directory
|
||||
if (directories.loading && !data && !current) return []
|
||||
const roots = [...(data ?? [])]
|
||||
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
|
||||
roots.sort((a, b) => {
|
||||
if (a.directory === current) return -1
|
||||
if (b.directory === current) return 1
|
||||
@@ -139,15 +137,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
(session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
||||
)
|
||||
.map((session) => session.location.directory)
|
||||
.filter((directory) => currentRoot() || directory !== currentDirectory())
|
||||
.filter((directory) => !roots.some((root) => root.directory === directory))
|
||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||
.map((location) => ({
|
||||
location,
|
||||
root: roots
|
||||
.filter((root) => {
|
||||
const relative = path.relative(root.directory, location)
|
||||
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
|
||||
})
|
||||
.filter((root) => contains(root.directory, location))
|
||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0],
|
||||
}))
|
||||
.filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined)
|
||||
@@ -325,6 +321,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
</box>
|
||||
}
|
||||
renderFilter={!showError()}
|
||||
flat={true}
|
||||
options={options()}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
@@ -357,7 +354,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
}}
|
||||
onMove={() => setToDelete(undefined)}
|
||||
actions={
|
||||
showError()
|
||||
showError() || props.fixture
|
||||
? []
|
||||
: [
|
||||
{
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
|
||||
// Holds one in-progress draft per slot across Prompt remounts. The undefined
|
||||
// key is the default single global slot that follows focus across tabs; the
|
||||
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
|
||||
// were written in. A draft is consumed on take: restoring it moves it out of
|
||||
// the stash, so a stale copy never shadows newer input.
|
||||
// Holds one in-progress draft per tab across Prompt remounts. A draft is
|
||||
// consumed on take: restoring it moves it out of the stash, so a stale copy
|
||||
// never shadows newer input.
|
||||
export type DraftEntry = { prompt: PromptInfo; cursor: number }
|
||||
|
||||
let global: DraftEntry | undefined
|
||||
const byTab = new Map<string, DraftEntry>()
|
||||
const byTab = new Map<string | undefined, DraftEntry>()
|
||||
|
||||
export function takeDraft(key: string | undefined) {
|
||||
if (key === undefined) {
|
||||
const entry = global
|
||||
global = undefined
|
||||
return entry
|
||||
}
|
||||
const entry = byTab.get(key)
|
||||
byTab.delete(key)
|
||||
export function takeDraft(sessionID: string | undefined) {
|
||||
const entry = byTab.get(sessionID)
|
||||
byTab.delete(sessionID)
|
||||
return entry
|
||||
}
|
||||
|
||||
export function saveDraft(key: string | undefined, entry: DraftEntry) {
|
||||
if (key === undefined) {
|
||||
global = entry
|
||||
return
|
||||
}
|
||||
byTab.set(key, entry)
|
||||
export function saveDraft(sessionID: string | undefined, entry: DraftEntry) {
|
||||
byTab.set(sessionID, entry)
|
||||
}
|
||||
|
||||
@@ -678,10 +678,9 @@ export function Prompt(props: PromptProps) {
|
||||
// instance belongs to exactly one tab. Reading props.sessionID lazily would
|
||||
// observe the *next* route during onCleanup and stash under the wrong tab.
|
||||
const stashSessionID = props.sessionID
|
||||
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
|
||||
|
||||
onMount(() => {
|
||||
const saved = takeDraft(stashKey())
|
||||
const saved = takeDraft(stashSessionID)
|
||||
if (store.prompt.text) return
|
||||
if (saved && saved.prompt.text) {
|
||||
input.setText(saved.prompt.text)
|
||||
@@ -694,7 +693,7 @@ export function Prompt(props: PromptProps) {
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
||||
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
@@ -408,10 +409,20 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
const fixture = tabs.detail?.(tab.sessionID)
|
||||
if (fixture !== undefined) return Locale.takeWidth(fixture, titleWidth())
|
||||
if (fixture !== undefined) return fixture
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
const currentProject = project()
|
||||
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
|
||||
const vcs = value ? data.location.vcs.info(value.location) : undefined
|
||||
const location = value ? data.location.info(value.location) : undefined
|
||||
const worktree = !!location && location.project.directory !== location.project.canonical
|
||||
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
|
||||
})
|
||||
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
|
||||
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
|
||||
const detailFades = createMemo(
|
||||
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const background = createMemo(() => {
|
||||
if (selected()) return theme.background.action.primary.selected
|
||||
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||
@@ -453,6 +464,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const detailTextColor = (index: number) =>
|
||||
detailFades()
|
||||
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
|
||||
: detailColor()
|
||||
const glows = () => status().glows
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
@@ -670,7 +685,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
{detail()}
|
||||
<Show when={detailFades()} fallback={visibleDetail()}>
|
||||
<For each={visibleDetailParts()}>
|
||||
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -192,13 +192,9 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Debugging settings" }),
|
||||
experimental: Schema.optional(
|
||||
Schema.Struct({
|
||||
tab_drafts: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Keep unsent prompt drafts on the tab where they were written",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Experimental features that may change or be removed at any time" }),
|
||||
experimental: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||
description: "Experimental features that may change or be removed at any time",
|
||||
}),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
||||
cursor: Schema.optional(Cursor),
|
||||
|
||||
@@ -94,7 +94,7 @@ type Store = {
|
||||
location: Record<string, LocationData>
|
||||
}
|
||||
|
||||
function locationKey(location: LocationRef) {
|
||||
export function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
||||
@@ -1214,9 +1214,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
default() {
|
||||
return defaultLocation()
|
||||
},
|
||||
async sync(ref?: LocationRef) {
|
||||
syncInfo(ref?: LocationRef) {
|
||||
const current = ref ?? defaultLocation()
|
||||
await sync.run(`location:${locationKey(current)}`, async () => {
|
||||
return sync.run(`location:${locationKey(current)}`, async () => {
|
||||
const location = await client.api.location.get({ location: locationQuery(current) })
|
||||
const key = locationKey(location)
|
||||
if (!store.location[key]) setStore("location", key, {})
|
||||
@@ -1225,6 +1225,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
||||
}
|
||||
})
|
||||
},
|
||||
async sync(ref?: LocationRef) {
|
||||
await result.location.syncInfo(ref)
|
||||
const location = ref ?? defaultLocation()
|
||||
await Promise.all([
|
||||
result.location.vcs.sync(location),
|
||||
|
||||
@@ -7,6 +7,7 @@ const context = createContext<{
|
||||
readonly current: LocationGetOutput | undefined
|
||||
// The target location as set, available before the server-synced info in `current` arrives.
|
||||
readonly ref: LocationRef | undefined
|
||||
readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined
|
||||
set: (location?: LocationRef) => void
|
||||
}>()
|
||||
|
||||
@@ -14,16 +15,29 @@ export function LocationProvider(props: ParentProps) {
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const [ref, setRef] = createSignal<LocationRef>()
|
||||
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
|
||||
let generation = 0
|
||||
const current = createMemo(() => data.location.info(ref()))
|
||||
|
||||
function sync(location?: LocationRef) {
|
||||
if (!location) return
|
||||
const attempt = ++generation
|
||||
const defaultLocation = data.location.default()
|
||||
const target =
|
||||
location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID
|
||||
? undefined
|
||||
: location
|
||||
void data.location.sync(target).catch(() => undefined)
|
||||
setError(undefined)
|
||||
void data.location.sync(target).catch((cause) => {
|
||||
const current = ref()
|
||||
if (
|
||||
generation !== attempt ||
|
||||
current?.directory !== location.directory ||
|
||||
current.workspaceID !== location.workspaceID
|
||||
)
|
||||
return
|
||||
setError({ location, cause })
|
||||
})
|
||||
}
|
||||
|
||||
function set(location?: LocationRef) {
|
||||
@@ -42,6 +56,9 @@ export function LocationProvider(props: ParentProps) {
|
||||
get ref() {
|
||||
return ref()
|
||||
},
|
||||
get error() {
|
||||
return error()
|
||||
},
|
||||
set,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -13,6 +13,16 @@ export function sessionTabShortcutLabel(index: number) {
|
||||
return "·"
|
||||
}
|
||||
|
||||
export function sessionTabDetail(
|
||||
project: string,
|
||||
current: string | undefined,
|
||||
defaultBranch: string | undefined,
|
||||
worktree: boolean,
|
||||
) {
|
||||
const branch = worktree && current !== defaultBranch ? current : undefined
|
||||
return branch && project ? `${project} ⎇ ${branch}` : (branch ?? project)
|
||||
}
|
||||
|
||||
export type SessionTabHistory = {
|
||||
entries: readonly string[]
|
||||
index: number
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { useData } from "./data"
|
||||
import { locationKey, useData } from "./data"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
@@ -159,9 +159,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
})
|
||||
|
||||
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
|
||||
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
|
||||
// connection slots and switches still render from a warm cache.
|
||||
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
|
||||
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
|
||||
// the first connection slots and switches still render from a warm cache.
|
||||
const openTabSessions = createMemo(() =>
|
||||
state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
@@ -171,10 +171,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
createEffect(() => {
|
||||
if (!enabled()) return
|
||||
if (client.connection.status() !== "connected") return
|
||||
const sessionIDs = openTabSessions()
|
||||
if (sessionIDs === "") return
|
||||
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
|
||||
const signature = openTabSessions()
|
||||
if (signature === "") return
|
||||
const sessionIDs = signature.split("\n")
|
||||
let stale = false
|
||||
void (async () => {
|
||||
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
|
||||
if (stale) return
|
||||
const locations = new Map(
|
||||
sessionIDs
|
||||
.map((sessionID) => data.session.get(sessionID)?.location)
|
||||
.filter((location) => location !== undefined)
|
||||
.map((location) => [locationKey(location), location]),
|
||||
)
|
||||
await Promise.allSettled(
|
||||
Array.from(locations.values(), (location) =>
|
||||
Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)]),
|
||||
),
|
||||
)
|
||||
})()
|
||||
const timer = setTimeout(async () => {
|
||||
const sessions = state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, For, type JSX } from "solid-js"
|
||||
import { StoryFooter } from "./footer"
|
||||
import { sessionTabsStory } from "./session-tabs"
|
||||
import { sessionLocationMissingStory } from "./session-location-missing"
|
||||
|
||||
/**
|
||||
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
|
||||
@@ -14,7 +15,7 @@ export type Story = {
|
||||
render: (context: Plugin.Context) => JSX.Element
|
||||
}
|
||||
|
||||
const stories: Story[] = [sessionTabsStory]
|
||||
const stories: Story[] = [sessionTabsStory, sessionLocationMissingStory]
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createSignal } from "solid-js"
|
||||
import { DialogMoveSession } from "../../../component/dialog-move-session"
|
||||
import { SessionLocationUnavailable } from "../../../routes/session/location-missing"
|
||||
import type { Story } from "./index"
|
||||
import { StoryFooter } from "./footer"
|
||||
|
||||
const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
|
||||
|
||||
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const [message, setMessage] = createSignal("Choose another directory to continue")
|
||||
const open = () =>
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DialogMoveSession
|
||||
projectID="fixture-project"
|
||||
initialDirectories={[
|
||||
{ directory: "/Users/kit/code/open-source/opencode" },
|
||||
{
|
||||
directory: "/Users/kit/code/open-source/opencode-instruction-rename",
|
||||
strategy: "git_worktree",
|
||||
},
|
||||
]}
|
||||
fixture
|
||||
onSelect={(selection) => {
|
||||
if (selection.type !== "directory") return
|
||||
setMessage(`Selected ${selection.directory}`)
|
||||
props.context.ui.dialog.clear()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
title: "Back to storybook",
|
||||
group: "Storybook",
|
||||
run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Workerd Modal workspace driver
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>build · GPT-5.6 Sol (high)</text>
|
||||
<box height={1} />
|
||||
<text fg={theme.text.default}>You</text>
|
||||
<text fg={theme.text.subdued}>Test the mounted workspace and verify the deployment.</text>
|
||||
<box height={1} />
|
||||
<text fg={theme.text.default}>Build · GPT-5.6 Sol (high)</text>
|
||||
<text fg={theme.text.subdued}>The deployment is verified and the worktree is clean.</text>
|
||||
<box flexGrow={1} />
|
||||
<SessionLocationUnavailable directory={directory} onMove={open} />
|
||||
</box>
|
||||
<StoryFooter
|
||||
context={props.context}
|
||||
title="storybook / missing session directory"
|
||||
status={message()}
|
||||
controls={[
|
||||
{ shortcut: "enter", label: "confirm" },
|
||||
{ shortcut: "esc", label: "back" },
|
||||
]}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export const sessionLocationMissingStory: Story = {
|
||||
id: "session-location-missing",
|
||||
title: "Missing session directory",
|
||||
render: (context) => <SessionLocationMissingStory context={context} />,
|
||||
}
|
||||
@@ -23,6 +23,19 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
|
||||
const selectedEntry = createMemo(() => entries()[store.selected])
|
||||
|
||||
const keymap = Keymap.use()
|
||||
createEffect(() => {
|
||||
if (!composer.active("shell")) return
|
||||
const cleanup = keymap.intercept("key", ({ event, consume }) => {
|
||||
if (event.name !== "d" || !event.ctrl) return
|
||||
if (!shortcuts.list("composer.shell.kill").includes("ctrl+d")) return
|
||||
if (!selectedEntry()) return
|
||||
consume()
|
||||
keymap.dispatch("composer.shell.kill")
|
||||
})
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1))
|
||||
})
|
||||
|
||||
@@ -108,6 +108,7 @@ import { createSingleFlight } from "../../util/single-flight"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||
import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -1115,6 +1116,19 @@ export function Session() {
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
session() &&
|
||||
currentLocation.error?.location.directory === session()!.location.directory &&
|
||||
currentLocation.error?.location.workspaceID === session()!.location.workspaceID
|
||||
}
|
||||
>
|
||||
<SessionLocationMissing
|
||||
directory={session()!.location.directory}
|
||||
projectID={session()!.projectID}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!disabled()}>
|
||||
<Prompt
|
||||
visible={true}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { abbreviateHome } from "../../util/path-format"
|
||||
import { SessionQuestion } from "./permission"
|
||||
import { usePromptMove } from "../../component/prompt/move"
|
||||
|
||||
export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) {
|
||||
const move = usePromptMove({ projectID: () => props.projectID, sessionID: () => props.sessionID })
|
||||
return <SessionLocationUnavailable directory={props.directory} onMove={move.open} />
|
||||
}
|
||||
|
||||
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
|
||||
const paths = useTuiPaths()
|
||||
const theme = useTheme("elevated")
|
||||
const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
|
||||
|
||||
return (
|
||||
<SessionQuestion
|
||||
id="session.location-missing"
|
||||
group="Session recovery"
|
||||
choicesLabel="Recovery actions"
|
||||
instance={props.directory}
|
||||
title="Session location unavailable"
|
||||
body={
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={theme.text.subdued}>{directory()}</text>
|
||||
<text fg={theme.text.default}>Choose another directory to continue this session.</text>
|
||||
</box>
|
||||
}
|
||||
options={{ move: "Choose directory" }}
|
||||
onSelect={props.onMove}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={store.stage === "always"}>
|
||||
<Prompt
|
||||
<SessionQuestion
|
||||
title="Always allow"
|
||||
semanticLabel={`Always allow ${props.request.action}`}
|
||||
instance={props.request.id}
|
||||
@@ -235,7 +235,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
)
|
||||
|
||||
const body = (
|
||||
<Prompt
|
||||
<SessionQuestion
|
||||
title="Permission required"
|
||||
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
|
||||
instance={props.request.id}
|
||||
@@ -411,10 +411,13 @@ function RejectPrompt(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function Prompt<const T extends Record<string, string>>(props: {
|
||||
export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
title: string
|
||||
semanticLabel?: string
|
||||
instance: string
|
||||
id?: string
|
||||
group?: string
|
||||
choicesLabel?: string
|
||||
header?: JSX.Element
|
||||
body: JSX.Element
|
||||
options: T
|
||||
@@ -431,86 +434,65 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
})
|
||||
const narrow = createMemo(() => dimensions().width < 80)
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const id = () => props.id ?? "session.permission"
|
||||
const group = () => props.group ?? "Permission"
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "base",
|
||||
commands: [
|
||||
{
|
||||
id: "app.exit",
|
||||
title: "Reject permission",
|
||||
group: "Permission",
|
||||
bind: false,
|
||||
run() {
|
||||
if (!props.escapeKey) return
|
||||
props.onSelect(props.escapeKey)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "permission.prompt.fullscreen",
|
||||
title: "Toggle permission fullscreen",
|
||||
group: "Permission",
|
||||
bind: false,
|
||||
run() {
|
||||
if (!props.fullscreen) return
|
||||
setStore("expanded", (v) => !v)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous permission option",
|
||||
group: "Permission",
|
||||
run: () => {
|
||||
const idx = keys.indexOf(store.selected)
|
||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
||||
setStore("selected", next)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "h",
|
||||
title: "Previous permission option",
|
||||
group: "Permission",
|
||||
run: () => {
|
||||
const idx = keys.indexOf(store.selected)
|
||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
||||
setStore("selected", next)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next permission option",
|
||||
group: "Permission",
|
||||
run: () => {
|
||||
const idx = keys.indexOf(store.selected)
|
||||
const next = keys[(idx + 1) % keys.length]
|
||||
setStore("selected", next)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "l",
|
||||
title: "Next permission option",
|
||||
group: "Permission",
|
||||
run: () => {
|
||||
const idx = keys.indexOf(store.selected)
|
||||
const next = keys[(idx + 1) % keys.length]
|
||||
setStore("selected", next)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "return",
|
||||
title: "Select permission option",
|
||||
group: "Permission",
|
||||
run: () => props.onSelect(store.selected),
|
||||
},
|
||||
...(props.escapeKey
|
||||
? [
|
||||
{
|
||||
bind: "escape",
|
||||
id: "app.exit",
|
||||
title: "Reject permission",
|
||||
group: "Permission",
|
||||
group: group(),
|
||||
bind: false as const,
|
||||
run: () => props.onSelect(props.escapeKey!),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(props.fullscreen
|
||||
? [
|
||||
{
|
||||
id: "permission.prompt.fullscreen",
|
||||
title: "Toggle permission fullscreen",
|
||||
group: group(),
|
||||
bind: false as const,
|
||||
run: () => setStore("expanded", (value) => !value),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(keys.length > 1
|
||||
? [
|
||||
{
|
||||
bind: "left,h",
|
||||
title: "Previous option",
|
||||
group: group(),
|
||||
run: () => {
|
||||
const index = keys.indexOf(store.selected)
|
||||
setStore("selected", keys[(index - 1 + keys.length) % keys.length])
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "right,l",
|
||||
title: "Next option",
|
||||
group: group(),
|
||||
run: () => {
|
||||
const index = keys.indexOf(store.selected)
|
||||
setStore("selected", keys[(index + 1) % keys.length])
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
bind: "return",
|
||||
title: "Select option",
|
||||
group: group(),
|
||||
run: () => props.onSelect(store.selected),
|
||||
},
|
||||
...(props.escapeKey
|
||||
? [{ bind: "escape", title: "Reject permission", group: group(), run: () => props.onSelect(props.escapeKey!) }]
|
||||
: []),
|
||||
],
|
||||
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
|
||||
}))
|
||||
@@ -520,7 +502,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
|
||||
const content = () => (
|
||||
<box
|
||||
id="session.permission"
|
||||
id={id()}
|
||||
ref={SimulationSemantics.bind(() => ({
|
||||
instance: props.instance,
|
||||
role: "dialog",
|
||||
@@ -571,11 +553,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
<box
|
||||
id="session.permission.actions"
|
||||
id={`${id()}.actions`}
|
||||
ref={SimulationSemantics.bind(() => ({
|
||||
instance: props.instance,
|
||||
role: "listbox",
|
||||
label: "Permission choices",
|
||||
label: props.choicesLabel ?? "Permission choices",
|
||||
}))}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
@@ -584,7 +566,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
<For each={keys}>
|
||||
{(option) => (
|
||||
<box
|
||||
id={`session.permission.action.${String(option)}`}
|
||||
id={`${id()}.action.${String(option)}`}
|
||||
ref={SimulationSemantics.bind(() => ({
|
||||
instance: props.instance,
|
||||
role: "option",
|
||||
@@ -621,9 +603,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text.default}>
|
||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
</text>
|
||||
<Show when={keys.length > 1}>
|
||||
<text fg={theme.text.default}>
|
||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||
</text>
|
||||
|
||||
@@ -23,7 +23,11 @@ const sessions = {
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
||||
|
||||
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
|
||||
async function renderComposer(
|
||||
defaultTab: "subagents" | "shell",
|
||||
keybinds: Partial<TuiKeybind.Keybinds>,
|
||||
focusedTextarea = false,
|
||||
) {
|
||||
const events = createEventStream()
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
@@ -69,7 +73,21 @@ async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Parti
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
.then(() => ready.resolve(), ready.reject)
|
||||
})
|
||||
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
return (
|
||||
<>
|
||||
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
|
||||
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AppExit() {
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "app.exit", title: "Exit", group: "System", run: () => {} }],
|
||||
}))
|
||||
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
@@ -77,6 +95,7 @@ async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Parti
|
||||
<TestTuiContexts directory={directory}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
|
||||
<Keymap.Provider>
|
||||
<AppExit />
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
@@ -154,6 +173,18 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("shell kill binding overrides app exit", async () => {
|
||||
const composer = await renderComposer("shell", {}, true)
|
||||
try {
|
||||
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||
composer.app.mockInput.pressKey("d", { ctrl: true })
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function session(id: string, title: string, parentID?: string) {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -11,11 +11,20 @@ import {
|
||||
reopenSessionTab,
|
||||
seedSessionTabMotion,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabShortcutLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
test("appends the branch to the project detail", () => {
|
||||
expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode ⎇ feature/sidebar")
|
||||
expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode ⎇ feature/sidebar")
|
||||
expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode")
|
||||
expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode")
|
||||
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
|
||||
})
|
||||
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
"1",
|
||||
|
||||
@@ -28,7 +28,14 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
||||
|
||||
async function renderSessionTabs(
|
||||
initialSessionID: string,
|
||||
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
|
||||
options?: {
|
||||
state?: string
|
||||
title?: string
|
||||
home?: boolean
|
||||
persisted?: string[]
|
||||
sessionGate?: Promise<void>
|
||||
sessionDirectories?: Record<string, string>
|
||||
},
|
||||
) {
|
||||
const temporary = options?.state ? undefined : await tmpdir()
|
||||
const state = options?.state ?? temporary!.path
|
||||
@@ -45,7 +52,25 @@ async function renderSessionTabs(
|
||||
}
|
||||
const events = createEventStream()
|
||||
const sessions: string[] = []
|
||||
const locations: string[] = []
|
||||
const vcsLocations: string[] = []
|
||||
const calls = createFetch(async (url) => {
|
||||
if (url.pathname === "/api/location") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
locations.push(requested)
|
||||
return json({
|
||||
directory: requested,
|
||||
project: { id: "project", directory: requested, canonical: directory },
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/vcs") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
vcsLocations.push(requested)
|
||||
return json({
|
||||
location: { directory: requested },
|
||||
data: { branch: { current: "main", default: "main" } },
|
||||
})
|
||||
}
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
if (!sessionID) return undefined
|
||||
sessions.push(sessionID)
|
||||
@@ -55,7 +80,7 @@ async function renderSessionTabs(
|
||||
id: sessionID,
|
||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
@@ -107,6 +132,8 @@ async function renderSessionTabs(
|
||||
route,
|
||||
data,
|
||||
sessions,
|
||||
locations,
|
||||
vcsLocations,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
async destroy() {
|
||||
@@ -137,6 +164,22 @@ test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("loads VCS metadata for each persisted tab location", async () => {
|
||||
const other = `${directory}/other-worktree`
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
sessionDirectories: { second: other },
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.locations.includes(other))
|
||||
await wait(() => setup.vcsLocations.includes(other))
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("stores session tabs for the current working directory by default", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
|
||||
@@ -3,23 +3,13 @@ import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash"
|
||||
import { emptyPrompt } from "../../src/prompt/history"
|
||||
|
||||
// The Prompt component stashes an unsent draft in onCleanup and takes it back
|
||||
// in onMount across route remounts. The key it uses is undefined by default
|
||||
// (one global slot that follows focus across tabs) and the tab identity
|
||||
// (sessionID, or "home") when the tab_drafts experiment is on.
|
||||
// in onMount across route remounts, keyed by sessionID or undefined for home.
|
||||
|
||||
function draft(text: string, cursor = text.length) {
|
||||
return { prompt: { ...emptyPrompt(), text }, cursor }
|
||||
}
|
||||
|
||||
describe("prompt draft stash", () => {
|
||||
test("global slot follows focus: any tab takes the last stashed draft", () => {
|
||||
const entry = draft("follow me")
|
||||
saveDraft(undefined, entry)
|
||||
expect(takeDraft(undefined)).toBe(entry)
|
||||
// Consumed on take, so a remount never restores a stale copy.
|
||||
expect(takeDraft(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tab-keyed drafts stay on the tab they were written in", () => {
|
||||
const two = draft("notes for session two")
|
||||
saveDraft("ses_two", two)
|
||||
@@ -37,25 +27,12 @@ describe("prompt draft stash", () => {
|
||||
const one = draft("DRAFT-ONE")
|
||||
const home = draft("draft on home")
|
||||
saveDraft("ses_one", one)
|
||||
saveDraft("home", home)
|
||||
saveDraft(undefined, home)
|
||||
|
||||
expect(takeDraft("home")).toBe(home)
|
||||
expect(takeDraft(undefined)).toBe(home)
|
||||
expect(takeDraft("ses_one")).toBe(one)
|
||||
})
|
||||
|
||||
test("global and tab slots never leak into each other when the experiment toggles mid-draft", () => {
|
||||
const global = draft("stashed before enabling tab_drafts")
|
||||
const keyed = draft("stashed after enabling tab_drafts")
|
||||
saveDraft(undefined, global)
|
||||
saveDraft("ses_a", keyed)
|
||||
|
||||
// A keyed lookup must not surface the global draft on the wrong tab...
|
||||
expect(takeDraft("ses_b")).toBeUndefined()
|
||||
// ...and the global slot must not surface a tab's draft.
|
||||
expect(takeDraft(undefined)).toBe(global)
|
||||
expect(takeDraft("ses_a")).toBe(keyed)
|
||||
})
|
||||
|
||||
test("a newer draft for the same slot replaces the older one", () => {
|
||||
saveDraft("ses_a", draft("first"))
|
||||
const second = draft("second")
|
||||
|
||||
@@ -124,10 +124,7 @@ export const dict: Record<string, string> = {
|
||||
|
||||
"ui.promptInput.noMatchingItems": "No matching items",
|
||||
"ui.promptInput.commands": "Commands",
|
||||
"ui.promptInput.dropFiles": "Drop files to add",
|
||||
"ui.promptInput.dropFiles.image": "Drop images or files to add",
|
||||
"ui.promptInput.dropFiles.pdf": "Drop PDFs or files to add",
|
||||
"ui.promptInput.dropFiles.imagePdf": "Drop images, PDFs, or files to add",
|
||||
"ui.promptInput.dropFiles": "Drop files to attach",
|
||||
"ui.promptInput.removeAttachment": "Remove attachment",
|
||||
"ui.promptInput.label": "Prompt",
|
||||
"ui.promptInput.placeholder.shell": "Enter shell command...",
|
||||
|
||||
Reference in New Issue
Block a user