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