Skip to content

Commit d8e126b

Browse files
authored
feat(tui): hidden experiments section with per-tab prompt drafts (#41862)
1 parent 7987aed commit d8e126b

9 files changed

Lines changed: 209 additions & 6 deletions

File tree

packages/plugin/src/tui/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,8 @@ export interface KeymapCommand {
372372
readonly aliases?: string[]
373373
/** Keeps the slash command in the prompt and passes its raw input to run. */
374374
readonly arguments?: true
375+
/** Hides the command from slash completion until its exact name is typed. */
376+
readonly secret?: true
375377
}
376378
/** Promotes the command in discovery UI. */
377379
readonly suggested?: boolean | (() => boolean)

packages/tui/src/app.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
batch,
3131
Show,
3232
} from "solid-js"
33-
import { createStore } from "solid-js/store"
33+
import { createStore, unwrap } from "solid-js/store"
3434
import {
3535
TuiLifecycleProvider,
3636
TuiAppProvider,
@@ -62,6 +62,7 @@ import { useConnected } from "./component/use-connected"
6262
import { DialogMcp } from "./component/dialog-mcp"
6363
import { DialogStatus } from "./component/dialog-status"
6464
import { DialogConfig } from "./component/dialog-config"
65+
import { DialogExperiments } from "./component/dialog-experiments"
6566
import { DialogDebug } from "./component/dialog-debug"
6667
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
6768
import { DialogThemeList } from "./component/dialog-theme-list"
@@ -657,8 +658,22 @@ function App(props: { pair?: DialogPairCredentials }) {
657658
category: "Session",
658659
slash: { name: "new", aliases: ["clear"] },
659660
run: () => {
661+
// With per-tab drafts, a new session is an explicit "this belongs
662+
// elsewhere" gesture: move the in-progress draft instead of leaving
663+
// a copy behind on the tab it came from.
664+
const carried = (() => {
665+
if (config.data.experimental?.tab_drafts !== true) return undefined
666+
const current = promptRef.current
667+
if (!current?.current.text) return undefined
668+
// Copy before reset: reset() merges an empty prompt into the same
669+
// underlying store object that unwrap exposes.
670+
const prompt = { ...unwrap(current.current) }
671+
current.reset()
672+
return prompt
673+
})()
660674
route.navigate({
661675
type: "home",
676+
prompt: carried,
662677
location:
663678
route.data.type === "session"
664679
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -870,6 +885,19 @@ function App(props: { pair?: DialogPairCredentials }) {
870885
},
871886
category: "System",
872887
},
888+
{
889+
// Deliberately absent from the command palette; reachable only by the
890+
// secret /baldbeard incantation.
891+
name: "opencode.experiments",
892+
title: "Experiments",
893+
description: "look is my devrel meme face",
894+
palette: undefined,
895+
slash: { name: "baldbeard", secret: true as const },
896+
run: () => {
897+
dialog.replace(() => <DialogExperiments />)
898+
},
899+
category: "System",
900+
},
873901
{
874902
name: "opencode.status",
875903
title: "View status",
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createMemo, createSignal } from "solid-js"
2+
import { useConfig } from "../config"
3+
import { DialogSelect } from "../ui/dialog-select"
4+
import { useToast } from "../ui/toast"
5+
6+
type Experiment = {
7+
id: "tab_drafts"
8+
title: string
9+
description: string
10+
}
11+
12+
// In-flight features anyone can opt into. Each entry is temporary: an
13+
// experiment either graduates (delete the entry, make the behavior
14+
// unconditional) or dies (delete the entry and the branch it gated).
15+
export const experiments: Experiment[] = [
16+
{
17+
id: "tab_drafts",
18+
title: "Per-tab prompt drafts",
19+
description: "Keep unsent prompt drafts on the tab where they were written. New session moves the current draft.",
20+
},
21+
]
22+
23+
export function DialogExperiments() {
24+
const config = useConfig()
25+
const toast = useToast()
26+
const [saving, setSaving] = createSignal(false)
27+
28+
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
29+
30+
const options = createMemo(() =>
31+
experiments.map((experiment, index) => ({
32+
title: experiment.title,
33+
description: experiment.description,
34+
category: "Experiments",
35+
footer: enabled(experiment) ? "on" : "off",
36+
value: index,
37+
})),
38+
)
39+
40+
async function toggle(index: number) {
41+
if (saving()) return
42+
const experiment = experiments[index]
43+
if (!experiment) return
44+
const next = !enabled(experiment)
45+
setSaving(true)
46+
await config
47+
.update((draft) => {
48+
if (!draft.experimental || typeof draft.experimental !== "object") draft.experimental = {}
49+
draft.experimental[experiment.id] = next
50+
})
51+
.catch(toast.error)
52+
.finally(() => setSaving(false))
53+
}
54+
55+
return (
56+
<DialogSelect
57+
title="Experiments"
58+
options={options()}
59+
onSelect={(option) => void toggle(option.value)}
60+
footerHints={[{ title: "enter", label: "toggle" }]}
61+
/>
62+
)
63+
}

packages/tui/src/component/prompt/autocomplete.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,9 @@ export function Autocomplete(props: {
512512
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
513513
const slash = command.slash
514514
if (!slash) return []
515+
// Secret commands are incantations: absent from the "/" listing and from
516+
// fuzzy matching until the exact name is typed.
517+
if (slash.secret && search().toLowerCase() !== slash.name) return []
515518
return {
516519
display: `/${slash.name}`,
517520
description: command.description ?? command.title,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { PromptInfo } from "../../prompt/history"
2+
3+
// Holds one in-progress draft per slot across Prompt remounts. The undefined
4+
// key is the default single global slot that follows focus across tabs; the
5+
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
6+
// were written in. A draft is consumed on take: restoring it moves it out of
7+
// the stash, so a stale copy never shadows newer input.
8+
export type DraftEntry = { prompt: PromptInfo; cursor: number }
9+
10+
let global: DraftEntry | undefined
11+
const byTab = new Map<string, DraftEntry>()
12+
13+
export function takeDraft(key: string | undefined) {
14+
if (key === undefined) {
15+
const entry = global
16+
global = undefined
17+
return entry
18+
}
19+
const entry = byTab.get(key)
20+
byTab.delete(key)
21+
return entry
22+
}
23+
24+
export function saveDraft(key: string | undefined, entry: DraftEntry) {
25+
if (key === undefined) {
26+
global = entry
27+
return
28+
}
29+
byTab.set(key, entry)
30+
}

packages/tui/src/component/prompt/index.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { parseSlashHead } from "../../prompt/parse"
2828
import { stringWidth } from "../../util/string-width"
2929
import { createStore, produce, unwrap } from "solid-js/store"
3030
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
31+
import { saveDraft, takeDraft } from "./draft-stash"
3132
import { Skill } from "@opencode-ai/schema/skill"
3233
import { computePromptTraits } from "../../prompt/traits"
3334
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
@@ -132,8 +133,6 @@ function formatEditorContext(selection: EditorSelection) {
132133
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
133134
}
134135

135-
let stashed: { prompt: PromptInfo; cursor: number } | undefined
136-
137136
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
138137
const head = parseSlashHead(input, /\s/)
139138
if (!head) return
@@ -657,9 +656,14 @@ export function Prompt(props: PromptProps) {
657656
},
658657
}
659658

659+
// Captured once: the session route is keyed by sessionID, so this Prompt
660+
// instance belongs to exactly one tab. Reading props.sessionID lazily would
661+
// observe the *next* route during onCleanup and stash under the wrong tab.
662+
const stashSessionID = props.sessionID
663+
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
664+
660665
onMount(() => {
661-
const saved = stashed
662-
stashed = undefined
666+
const saved = takeDraft(stashKey())
663667
if (store.prompt.text) return
664668
if (saved && saved.prompt.text) {
665669
input.setText(saved.prompt.text)
@@ -672,7 +676,7 @@ export function Prompt(props: PromptProps) {
672676
onCleanup(() => {
673677
disposed = true
674678
if (store.prompt.text) {
675-
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
679+
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
676680
}
677681
setInputTarget(undefined)
678682
props.ref?.(undefined)

packages/tui/src/config/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,13 @@ export const Info = Schema.Struct({
189189
}),
190190
}),
191191
).annotate({ description: "Debugging settings" }),
192+
experimental: Schema.optional(
193+
Schema.Struct({
194+
tab_drafts: Schema.optional(Schema.Boolean).annotate({
195+
description: "Keep unsent prompt drafts on the tab where they were written",
196+
}),
197+
}),
198+
).annotate({ description: "Experimental features that may change or be removed at any time" }),
192199
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
193200
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
194201
cursor: Schema.optional(Cursor),

packages/tui/src/context/keymap.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ declare module "@opentui/keymap" {
2424
name: string
2525
aliases?: string[]
2626
arguments?: true
27+
secret?: true
2728
}
2829
}
2930
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash"
3+
import { emptyPrompt } from "../../src/prompt/history"
4+
5+
// The Prompt component stashes an unsent draft in onCleanup and takes it back
6+
// in onMount across route remounts. The key it uses is undefined by default
7+
// (one global slot that follows focus across tabs) and the tab identity
8+
// (sessionID, or "home") when the tab_drafts experiment is on.
9+
10+
function draft(text: string, cursor = text.length) {
11+
return { prompt: { ...emptyPrompt(), text }, cursor }
12+
}
13+
14+
describe("prompt draft stash", () => {
15+
test("global slot follows focus: any tab takes the last stashed draft", () => {
16+
const entry = draft("follow me")
17+
saveDraft(undefined, entry)
18+
expect(takeDraft(undefined)).toBe(entry)
19+
// Consumed on take, so a remount never restores a stale copy.
20+
expect(takeDraft(undefined)).toBeUndefined()
21+
})
22+
23+
test("tab-keyed drafts stay on the tab they were written in", () => {
24+
const two = draft("notes for session two")
25+
saveDraft("ses_two", two)
26+
27+
// Switching to another tab or home finds nothing.
28+
expect(takeDraft("ses_one")).toBeUndefined()
29+
expect(takeDraft("home")).toBeUndefined()
30+
31+
// Returning to the original tab restores exactly its draft, once.
32+
expect(takeDraft("ses_two")).toBe(two)
33+
expect(takeDraft("ses_two")).toBeUndefined()
34+
})
35+
36+
test("each tab keeps its own draft, including home", () => {
37+
const one = draft("DRAFT-ONE")
38+
const home = draft("draft on home")
39+
saveDraft("ses_one", one)
40+
saveDraft("home", home)
41+
42+
expect(takeDraft("home")).toBe(home)
43+
expect(takeDraft("ses_one")).toBe(one)
44+
})
45+
46+
test("global and tab slots never leak into each other when the experiment toggles mid-draft", () => {
47+
const global = draft("stashed before enabling tab_drafts")
48+
const keyed = draft("stashed after enabling tab_drafts")
49+
saveDraft(undefined, global)
50+
saveDraft("ses_a", keyed)
51+
52+
// A keyed lookup must not surface the global draft on the wrong tab...
53+
expect(takeDraft("ses_b")).toBeUndefined()
54+
// ...and the global slot must not surface a tab's draft.
55+
expect(takeDraft(undefined)).toBe(global)
56+
expect(takeDraft("ses_a")).toBe(keyed)
57+
})
58+
59+
test("a newer draft for the same slot replaces the older one", () => {
60+
saveDraft("ses_a", draft("first"))
61+
const second = draft("second")
62+
saveDraft("ses_a", second)
63+
expect(takeDraft("ses_a")).toBe(second)
64+
})
65+
})

0 commit comments

Comments
 (0)