feat(tui): graduate per-tab prompt drafts (#42358)

This commit is contained in:
Kit Langton 2026-08-13 12:57:53 -04:00 committed by GitHub
parent 595e4c8c96
commit 642772e2a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 50 additions and 85 deletions

View file

@ -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(),
},
]
: []
}
/>
)
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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),

View file

@ -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")