diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 9db2a623618..04b9a4f1713 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -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() 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() { 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={ + + No experiments available + + } + 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(), + }, + ] + : [] + } /> ) } diff --git a/packages/tui/src/component/prompt/draft-stash.ts b/packages/tui/src/component/prompt/draft-stash.ts index 3c736b71987..eaec92e8b0e 100644 --- a/packages/tui/src/component/prompt/draft-stash.ts +++ b/packages/tui/src/component/prompt/draft-stash.ts @@ -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() +const byTab = new Map() -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) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index af2f40bf793..7c3d1c378d0 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 52f52ffeb9a..2cd49a3f642 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -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), diff --git a/packages/tui/test/prompt/draft-stash.test.ts b/packages/tui/test/prompt/draft-stash.test.ts index df04ed7e8e7..0ccea23dd4b 100644 --- a/packages/tui/test/prompt/draft-stash.test.ts +++ b/packages/tui/test/prompt/draft-stash.test.ts @@ -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")