mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 17:13:25 +00:00
feat(tui): hidden experiments section with per-tab prompt drafts (#41862)
This commit is contained in:
parent
7987aed8f3
commit
d8e126b817
9 changed files with 209 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(() => <DialogExperiments />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "opencode.status",
|
||||
title: "View status",
|
||||
|
|
|
|||
63
packages/tui/src/component/dialog-experiments.tsx
Normal file
63
packages/tui/src/component/dialog-experiments.tsx
Normal file
|
|
@ -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 (
|
||||
<DialogSelect
|
||||
title="Experiments"
|
||||
options={options()}
|
||||
onSelect={(option) => void toggle(option.value)}
|
||||
footerHints={[{ title: "enter", label: "toggle" }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
30
packages/tui/src/component/prompt/draft-stash.ts
Normal file
30
packages/tui/src/component/prompt/draft-stash.ts
Normal file
|
|
@ -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<string, 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)
|
||||
return entry
|
||||
}
|
||||
|
||||
export function saveDraft(key: string | undefined, entry: DraftEntry) {
|
||||
if (key === undefined) {
|
||||
global = entry
|
||||
return
|
||||
}
|
||||
byTab.set(key, entry)
|
||||
}
|
||||
|
|
@ -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 `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ declare module "@opentui/keymap" {
|
|||
name: string
|
||||
aliases?: string[]
|
||||
arguments?: true
|
||||
secret?: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
packages/tui/test/prompt/draft-stash.test.ts
Normal file
65
packages/tui/test/prompt/draft-stash.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue