Browse Source

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

Kit Langton 1 day ago
parent
commit
642772e2a5

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

+ 9 - 21
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<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)
 }

+ 2 - 3
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)

+ 3 - 7
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),

+ 3 - 26
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")