mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 02:53:26 +00:00
feat(tui): graduate per-tab prompt drafts (#42358)
This commit is contained in:
parent
595e4c8c96
commit
642772e2a5
5 changed files with 50 additions and 85 deletions
|
|
@ -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(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue