diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts
index e1ba963131d..616b266a28d 100644
--- a/packages/plugin/src/tui/context.ts
+++ b/packages/plugin/src/tui/context.ts
@@ -372,6 +372,8 @@ export interface KeymapCommand {
readonly aliases?: string[]
/** Keeps the slash command in the prompt and passes its raw input to run. */
readonly arguments?: true
+ /** Hides the command from slash completion until its exact name is typed. */
+ readonly secret?: true
}
/** Promotes the command in discovery UI. */
readonly suggested?: boolean | (() => boolean)
diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index 377ebb598be..dbf2a6299b8 100644
--- a/packages/tui/src/app.tsx
+++ b/packages/tui/src/app.tsx
@@ -30,7 +30,7 @@ import {
batch,
Show,
} from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, unwrap } from "solid-js/store"
import {
TuiLifecycleProvider,
TuiAppProvider,
@@ -62,6 +62,7 @@ import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
+import { DialogExperiments } from "./component/dialog-experiments"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogThemeList } from "./component/dialog-theme-list"
@@ -657,8 +658,22 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
+ // With per-tab drafts, a new session is an explicit "this belongs
+ // elsewhere" gesture: move the in-progress draft instead of leaving
+ // a copy behind on the tab it came from.
+ const carried = (() => {
+ if (config.data.experimental?.tab_drafts !== true) return undefined
+ const current = promptRef.current
+ if (!current?.current.text) return undefined
+ // Copy before reset: reset() merges an empty prompt into the same
+ // underlying store object that unwrap exposes.
+ const prompt = { ...unwrap(current.current) }
+ current.reset()
+ return prompt
+ })()
route.navigate({
type: "home",
+ prompt: carried,
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@@ -870,6 +885,19 @@ function App(props: { pair?: DialogPairCredentials }) {
},
category: "System",
},
+ {
+ // Deliberately absent from the command palette; reachable only by the
+ // secret /baldbeard incantation.
+ name: "opencode.experiments",
+ title: "Experiments",
+ description: "look is my devrel meme face",
+ palette: undefined,
+ slash: { name: "baldbeard", secret: true as const },
+ run: () => {
+ dialog.replace(() => )
+ },
+ category: "System",
+ },
{
name: "opencode.status",
title: "View status",
diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx
new file mode 100644
index 00000000000..dda144515f9
--- /dev/null
+++ b/packages/tui/src/component/dialog-experiments.tsx
@@ -0,0 +1,63 @@
+import { createMemo, createSignal } from "solid-js"
+import { useConfig } from "../config"
+import { DialogSelect } from "../ui/dialog-select"
+import { useToast } from "../ui/toast"
+
+type Experiment = {
+ id: "tab_drafts"
+ title: string
+ description: string
+}
+
+// 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 session moves the current draft.",
+ },
+]
+
+export function DialogExperiments() {
+ const config = useConfig()
+ const toast = useToast()
+ const [saving, setSaving] = createSignal(false)
+
+ const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
+
+ const options = createMemo(() =>
+ experiments.map((experiment, index) => ({
+ title: experiment.title,
+ description: experiment.description,
+ category: "Experiments",
+ footer: enabled(experiment) ? "on" : "off",
+ value: index,
+ })),
+ )
+
+ async function toggle(index: number) {
+ if (saving()) return
+ const experiment = experiments[index]
+ if (!experiment) return
+ const next = !enabled(experiment)
+ setSaving(true)
+ await config
+ .update((draft) => {
+ if (!draft.experimental || typeof draft.experimental !== "object") draft.experimental = {}
+ draft.experimental[experiment.id] = next
+ })
+ .catch(toast.error)
+ .finally(() => setSaving(false))
+ }
+
+ return (
+ void toggle(option.value)}
+ footerHints={[{ title: "enter", label: "toggle" }]}
+ />
+ )
+}
diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx
index 4600491f90c..e018fec3bcb 100644
--- a/packages/tui/src/component/prompt/autocomplete.tsx
+++ b/packages/tui/src/component/prompt/autocomplete.tsx
@@ -512,6 +512,9 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
const slash = command.slash
if (!slash) return []
+ // Secret commands are incantations: absent from the "/" listing and from
+ // fuzzy matching until the exact name is typed.
+ if (slash.secret && search().toLowerCase() !== slash.name) return []
return {
display: `/${slash.name}`,
description: command.description ?? command.title,
diff --git a/packages/tui/src/component/prompt/draft-stash.ts b/packages/tui/src/component/prompt/draft-stash.ts
new file mode 100644
index 00000000000..3c736b71987
--- /dev/null
+++ b/packages/tui/src/component/prompt/draft-stash.ts
@@ -0,0 +1,30 @@
+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.
+export type DraftEntry = { prompt: PromptInfo; cursor: number }
+
+let global: DraftEntry | undefined
+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)
+ return entry
+}
+
+export function saveDraft(key: string | undefined, entry: DraftEntry) {
+ if (key === undefined) {
+ global = entry
+ return
+ }
+ byTab.set(key, entry)
+}
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index 8c716bece49..44bc0764384 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -28,6 +28,7 @@ import { parseSlashHead } from "../../prompt/parse"
import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
+import { saveDraft, takeDraft } from "./draft-stash"
import { Skill } from "@opencode-ai/schema/skill"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
@@ -132,8 +133,6 @@ function formatEditorContext(selection: EditorSelection) {
return `${ranges.join("\n")} This may or may not be relevant to the current task.\n`
}
-let stashed: { prompt: PromptInfo; cursor: number } | undefined
-
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/)
if (!head) return
@@ -657,9 +656,14 @@ export function Prompt(props: PromptProps) {
},
}
+ // Captured once: the session route is keyed by sessionID, so this Prompt
+ // 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 = stashed
- stashed = undefined
+ const saved = takeDraft(stashKey())
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
@@ -672,7 +676,7 @@ export function Prompt(props: PromptProps) {
onCleanup(() => {
disposed = true
if (store.prompt.text) {
- stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
+ saveDraft(stashKey(), { 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 8a44a505bac..2c9e90b3362 100644
--- a/packages/tui/src/config/index.tsx
+++ b/packages/tui/src/config/index.tsx
@@ -189,6 +189,13 @@ 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" }),
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/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx
index a7a28efa127..0cfa8737dcd 100644
--- a/packages/tui/src/context/keymap.tsx
+++ b/packages/tui/src/context/keymap.tsx
@@ -24,6 +24,7 @@ declare module "@opentui/keymap" {
name: string
aliases?: string[]
arguments?: true
+ secret?: true
}
}
}
diff --git a/packages/tui/test/prompt/draft-stash.test.ts b/packages/tui/test/prompt/draft-stash.test.ts
new file mode 100644
index 00000000000..df04ed7e8e7
--- /dev/null
+++ b/packages/tui/test/prompt/draft-stash.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, test } from "bun:test"
+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.
+
+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)
+
+ // Switching to another tab or home finds nothing.
+ expect(takeDraft("ses_one")).toBeUndefined()
+ expect(takeDraft("home")).toBeUndefined()
+
+ // Returning to the original tab restores exactly its draft, once.
+ expect(takeDraft("ses_two")).toBe(two)
+ expect(takeDraft("ses_two")).toBeUndefined()
+ })
+
+ test("each tab keeps its own draft, including home", () => {
+ const one = draft("DRAFT-ONE")
+ const home = draft("draft on home")
+ saveDraft("ses_one", one)
+ saveDraft("home", home)
+
+ expect(takeDraft("home")).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")
+ saveDraft("ses_a", second)
+ expect(takeDraft("ses_a")).toBe(second)
+ })
+})