feat(app): add searchable prompt history picker (#57716)

This commit is contained in:
usrnk1
2026-10-21 12:15:26 +02:00
committed by GitHub
Unverified
parent 46dd7715e6
commit 3449855dab
12 changed files with 1079 additions and 309 deletions
@@ -1,5 +1,310 @@
import { expect, story } from "../../storybook/playwright/story"
story("cycles full prompts with plain arrows without opening the picker", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--prompt-history")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const options = component.getByRole("option")
const composer = component.locator('[data-component="composer"]')
const content = component.locator('[data-slot="composer-content"]')
await page.evaluate(() => document.fonts.ready.then(() => undefined))
const height = await composer.evaluate((element) => element.getBoundingClientRect().height)
await input.press("ArrowUp")
await expect(input).toHaveText("Explain the latest changes")
await expect(input).toBeFocused()
await expect(options).toHaveCount(0)
await expect(content).toHaveCSS("mask-image", "none")
await input.press("ArrowUp")
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible()
await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible()
await expect
.poll(() => composer.evaluate((element) => element.getBoundingClientRect().height))
.toBeGreaterThan(height)
await expect(options).toHaveCount(0)
await input.press("ArrowUp")
await expect(input).toHaveText("Run the focused tests")
await input.press("ArrowUp")
await expect(input).toHaveText("Run the focused tests")
await input.press("ArrowDown")
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await input.press("ArrowDown")
await expect(input).toHaveText("Explain the latest changes")
await input.press("ArrowDown")
await expect(input).toBeEmpty()
await expect(composer).toHaveCSS("height", `${height}px`)
// A new cycle saves and restores an empty-text draft's attachments and comments.
await input.press("ArrowUp")
await input.press("ArrowUp")
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await input.fill("")
await input.press("ArrowUp")
await expect(input).toHaveText("Explain the latest changes")
await expect(component.getByRole("img", { name: "layout.png", exact: true })).toHaveCount(0)
await input.press("ArrowDown")
await expect(input).toBeEmpty()
await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible()
await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible()
await input.press("ArrowUp")
await input.press("ArrowUp")
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await input.press("ArrowRight")
await input.press("Shift+ArrowUp")
await expect(options).toHaveCount(0)
await expect.poll(() => input.evaluate(() => window.getSelection()?.toString())).toBe("R")
await input.press("ArrowRight")
await input.pressSequentially("!")
await expect(input).toHaveText("R!eview the composer layout\nKeep keyboard navigation accessible")
await input.press("ArrowUp")
await expect(input).toHaveText("R!eview the composer layout\nKeep keyboard navigation accessible")
await input.fill("")
await input.press("ArrowUp")
await expect(input).toHaveText("Explain the latest changes")
await input.press("Enter")
await expect(input).toBeEmpty()
await expect(component.getByRole("status")).toHaveText("Submitted: Explain the latest changes")
await input.press("ArrowUp")
await expect(input).toHaveText("Explain the latest changes")
await expect(options).toHaveCount(0)
await input.fill("!")
await expect(input).toBeEmpty()
await input.press("ArrowUp")
await expect(input).toHaveText("bun typecheck")
await expect(options).toHaveCount(0)
await input.press("ArrowDown")
await expect(input).toBeEmpty()
await input.press("ArrowUp")
await input.press("Enter")
await expect(component.getByRole("status")).toHaveText("Submitted: bun typecheck")
})
story(
"previews history in the composer, accepts without sending, and cancels back to the original draft",
async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--prompt-history")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const search = component.getByRole("combobox", { name: "Prompt history", exact: true })
const options = component.getByRole("option")
const composer = component.locator('[data-component="composer"]')
const content = component.locator('[data-slot="composer-content"]')
const viewport = component.locator('[data-component="composer-scroll"] > .scroll-view__viewport')
await page.evaluate(() => document.fonts.ready.then(() => undefined))
const height = await composer.evaluate((element) => element.getBoundingClientRect().height)
await input.press("Shift+ArrowUp")
await expect(input).toBeFocused()
await expect(search).toHaveCount(0)
await expect(input).toHaveText("Explain the latest changes")
await expect(composer).toHaveCSS("height", `${height}px`)
const menu = await component.locator('[data-component="composer-suggestions"]').boundingBox()
if (!menu) throw new Error("Missing history menu")
await expect(options).toHaveText([
"Run the focused tests",
"Review the composer layout Keep keyboard navigation accessible",
"Explain the latest changes",
])
await expect(component.getByRole("option", { selected: true })).toHaveText("Explain the latest changes")
await input.press("ArrowUp")
await expect(component.getByRole("option", { selected: true })).toHaveText(
"Review the composer layout Keep keyboard navigation accessible",
)
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await expect(composer).toHaveCSS("height", `${height}px`)
await expect
.poll(() => component.locator('[data-slot="composer-content"]').evaluate((element) => element.scrollTop))
.toBeGreaterThan(0)
await expect(input).toBeInViewport()
await expect
.poll(() =>
input.evaluate(
(element) =>
element.getBoundingClientRect().top -
element.closest('[data-slot="composer-content"]')!.getBoundingClientRect().top,
),
)
.toBe(0)
await expect(content).not.toHaveCSS("mask-image", "none")
await expect(viewport).toHaveCSS("mask-image", "none")
await expect(content).toHaveCSS("--composer-fade-bottom", "16px")
await content.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await expect(content).toHaveCSS("--composer-fade-bottom", "0px")
await expect
.poll(async () => (await component.locator('[data-component="composer-suggestions"]').boundingBox())?.y)
.toBe(menu.y)
await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible()
await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible()
await input.press("ArrowUp")
await expect(input).toHaveText("Run the focused tests")
await expect(composer).toHaveCSS("height", `${height}px`)
await input.press("ArrowUp")
await expect(input).toHaveText("Run the focused tests")
await input.press("ArrowDown")
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await input.press("ControlOrMeta+f")
await expect(search).toBeFocused()
await search.fill("ACCESSIBLE")
await expect(composer).toHaveCSS("height", `${height}px`)
await search.press("Escape")
await expect(input).toBeFocused()
await expect(composer).toHaveCSS("height", `${height}px`)
await expect
.poll(() =>
component
.locator('[data-slot="composer-content"]')
.evaluate((element) => element.scrollHeight > element.clientHeight),
)
.toBe(true)
await input.press("Enter")
await expect(options).toHaveCount(0)
await expect(content).toHaveCSS("mask-image", "none")
await expect(viewport).not.toHaveCSS("mask-image", "none")
await expect(content).toHaveCSS("--composer-fade-bottom", "0px")
await expect(input).toBeFocused()
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await expect(component.getByRole("status")).toHaveText("Ready")
await expect
.poll(() => composer.evaluate((element) => element.getBoundingClientRect().height))
.toBeGreaterThan(height)
// History must not intercept navigation in a populated multiline editor.
await input.press("ControlOrMeta+Home")
await input.press("ArrowUp")
await expect(search).toHaveCount(0)
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
// An empty-text draft can still have attachments and comments. Previewing must not lose them on cancel.
await input.fill("")
const restoredHeight = await composer.evaluate((element) => element.getBoundingClientRect().height)
await input.press("Shift+ArrowUp")
await expect(input).toHaveText("Explain the latest changes")
await expect(component.getByRole("img", { name: "layout.png", exact: true })).toHaveCount(0)
await expect(composer).toHaveCSS("height", `${restoredHeight}px`)
await input.press("Escape")
await expect(options).toHaveCount(0)
await expect(input).toBeFocused()
await expect(input).toBeEmpty()
await expect(composer).toHaveCSS("height", `${restoredHeight}px`)
await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible()
await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible()
},
)
story("searches history on demand, returns to browsing, and keeps shell history separate", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--prompt-history")
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const search = component.getByRole("combobox", { name: "Prompt history", exact: true })
await input.press("Shift+ArrowUp")
await input.press("ControlOrMeta+f")
await expect(search).toBeFocused()
await search.fill("ACCESSIBLE")
await expect(component.getByRole("option")).toHaveCount(1)
await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible")
await expect(search).toBeFocused()
await search.press("Escape")
await expect(search).toHaveCount(0)
await expect(input).toBeFocused()
await expect(component.getByRole("option")).toHaveCount(3)
await input.press("Escape")
await expect(component.getByRole("option")).toHaveCount(0)
await expect(input).toBeEmpty()
await input.press("Shift+ArrowUp")
await component.getByRole("button", { name: /^Search/ }).click()
await expect(search).toBeFocused()
await search.fill("no matching prompt")
await expect(component.getByRole("option")).toHaveCount(0)
await expect(component.getByText("No matching items", { exact: true })).toBeVisible()
await search.press("ArrowUp")
await search.press("Enter")
await expect(search).toHaveValue("no matching prompt")
await expect(input).toHaveText("Explain the latest changes")
await search.fill("tests")
await expect(input).toHaveText("Run the focused tests")
await expect(search).toBeFocused()
await search.press("Enter")
await expect(search).toHaveCount(0)
await expect(input).toBeFocused()
await expect(component.getByRole("status")).toHaveText("Ready")
await input.fill("")
await input.press("Shift+ArrowUp")
await component.getByRole("option", { name: "Run the focused tests", exact: true }).click()
await expect(input).toHaveText("Run the focused tests")
await expect(input).toBeFocused()
await expect(component.getByRole("status")).toHaveText("Ready")
await page.mouse.move(0, 0)
await input.fill("")
await input.press("Shift+ArrowUp")
await expect(input).toHaveText("Explain the latest changes")
await expect(component.getByRole("option")).toHaveCount(3)
await expect(input).toBeFocused()
await input.press("Shift+Digit1")
await expect(component.getByRole("option")).toHaveCount(0)
await expect(input).toHaveText("Explain the latest changes!")
await input.fill("!")
await expect(input).toBeEmpty()
await input.press("Shift+ArrowUp")
await expect(input).toHaveText("bun typecheck")
await expect(component.getByRole("option")).toHaveText(["bun typecheck"])
await input.press("Escape")
await expect(input).toBeFocused()
await expect(input).toBeEmpty()
await input.press("Shift+ArrowUp")
await input.press("Enter")
await expect(input).toHaveText("bun typecheck")
await expect(component.getByRole("status")).toHaveText("Ready")
})
story("pins history search above the scrolling prompts", async ({ mount }) => {
const component = await mount("opencode-composer-flow--prompt-history", { args: { many: true } })
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
await input.press("Shift+ArrowUp")
const menu = component.locator('[data-component="composer-suggestions"]')
const search = menu.getByRole("button", { name: /^Search/ })
const viewport = menu.getByRole("region", { name: "scrollable content" })
await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
const clippedTop = () =>
viewport.evaluate((element) => {
const top = element.getBoundingClientRect().top
return Array.from(element.querySelectorAll('[role="option"]')).some((row) => {
const rect = row.getBoundingClientRect()
return rect.top < top && rect.bottom > top
})
})
await expect.poll(clippedTop).toBe(false)
for (let index = 0; index < 12; index++) await input.press("ArrowUp")
await expect(menu.getByRole("option", { selected: true })).toBeInViewport({ ratio: 1 })
await expect.poll(clippedTop).toBe(false)
await expect.poll(async () => (await search.boundingBox())!.y - (await viewport.boundingBox())!.y).toBeLessThan(0)
const top = (await search.boundingBox())!.y
await viewport.evaluate((element) => {
element.scrollTop = 0
})
await expect(search).toBeInViewport()
expect((await search.boundingBox())!.y).toBe(top)
await viewport.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await expect(search).toBeInViewport()
expect((await search.boundingBox())!.y).toBe(top)
})
for (const direction of ["ltr", "rtl"]) {
for (const alternate of ["none", "queue", "steer"]) {
story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => {
+60 -1
View File
@@ -60,6 +60,8 @@ function ComposerStory(props: {
longLabels?: boolean
alternate?: "queue" | "steer"
manySuggestions?: boolean
history?: boolean
manyHistory?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
@@ -149,6 +151,51 @@ function ComposerStory(props: {
commands: () => commands,
context: () => context,
searchContextFiles: () => [],
history: props.history
? {
entries: (mode) =>
mode === "shell"
? [{ prompt: text("bun typecheck") }]
: [
{ prompt: text("Explain the latest changes") },
{
prompt: [
...text("Review the composer layout\nKeep keyboard navigation accessible"),
{
type: "path",
id: "history-layout",
filename: "layout.png",
mime: "image/png",
path: "C:/shots/layout.png",
},
],
metadata: "review",
},
{ prompt: text("Run the focused tests") },
...(props.manyHistory
? Array.from({ length: 16 }, (_, index) => ({ prompt: text(`Earlier prompt ${index + 1}`) }))
: []),
],
add() {},
capture: () => (draft.context.items.some((item) => item.key === "history-comment") ? "review" : undefined),
restore: (metadata) =>
setDraft(
"context",
"items",
metadata === "review"
? [
{
type: "file",
key: "history-comment",
path: "src/composer.tsx",
selection: { startLine: 12, startChar: 0, endLine: 14, endChar: 0 },
comment: "Keep the search separate from the draft",
},
]
: [],
),
}
: undefined,
readImage: async () => pixel,
view: {
placeholder: () => "Ask anything, / for commands, @ for context...",
@@ -231,7 +278,10 @@ function ComposerStory(props: {
})
return (
<div class="mx-auto flex min-h-80 w-full max-w-200 flex-col justify-end gap-3 rounded-xl bg-v2-background-bg-deep p-6">
<div
class="mx-auto flex min-h-80 w-full max-w-200 flex-col justify-end gap-3 rounded-xl bg-v2-background-bg-deep p-6"
style={{ "min-height": props.manyHistory ? "512px" : undefined }}
>
<output class="text-12-regular text-text-weak" aria-live="polite">
{story.activity}
</output>
@@ -252,6 +302,15 @@ export default {
export const EmptyDraft = { render: () => <ComposerStory /> }
export const PromptHistory = {
args: { many: false },
render: (args: { many: boolean }) => (
<div class="w-[min(640px,calc(100vw-32px))]">
<ComposerStory history manyHistory={args.many} />
</div>
),
}
export const TextDraft = { render: () => <ComposerStory prompt={text("Explain this change")} /> }
export const MultilineDraft = {
+7 -1
View File
@@ -12,7 +12,8 @@
/* Matches the 16px session title fade. The top fade only covers the editor's 16px top padding until text scrolls
under it; the mask sits on the viewport so the thumb stays solid. */
[data-component="composer-scroll"] > .scroll-view__viewport {
[data-component="composer-scroll"] > .scroll-view__viewport,
[data-slot="composer-content"][data-history-preview] {
mask-image: linear-gradient(
to bottom,
transparent,
@@ -22,6 +23,11 @@
);
}
/* History scrolls text and attachments together, so only the outer viewport applies the mask. */
[data-slot="composer-content"][data-history-preview] [data-component="composer-scroll"] > .scroll-view__viewport {
mask-image: none;
}
/* Masks only cover hidden content; they do not reserve space in the toolbar. */
[data-slot="composer-controls"][data-overflow-start="true"] {
--mask-start: 16px;
+371 -180
View File
@@ -3,6 +3,7 @@ import {
createMemo,
createResource,
createSignal,
createUniqueId,
For,
onCleanup,
onMount,
@@ -45,8 +46,10 @@ import type {
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import { QuoteCommentEditor } from "./quote-comment"
import { formatKeybindParts } from "@/shell/commands/command"
import "../attachments/attachments.css"
import "./editor.css"
import "@/shell/palette-menu.css"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
@@ -95,11 +98,35 @@ export function ComposerEditor(props: ComposerEditorProps) {
const i18n = useI18n()
const state = props.controller.state
const view = props.controller.view
const suggestionsID = createUniqueId()
let composer: HTMLFormElement | undefined
let content: HTMLDivElement | undefined
let editor: HTMLDivElement | undefined
let viewport: HTMLDivElement | undefined
let controlsViewport!: HTMLDivElement
let controlsContent!: HTMLDivElement
const [overflow, setOverflow] = createStore({ start: false, end: false })
const [historyLayout, setHistoryLayout] = createStore<{ height?: number }>({})
createEffect(() => {
if (state.popover.type !== "history") setHistoryLayout("height", undefined)
})
createEffect(() => {
const preview = historyLayout.height !== undefined
if (preview) props.controller.parts()
// Show the beginning of each prompt; attachments remain accessible above it in the same viewport.
const frame = requestAnimationFrame(() => {
if (preview && content && editor)
content.scrollTop += editor.getBoundingClientRect().top - content.getBoundingClientRect().top
updateScroll()
})
onCleanup(() => cancelAnimationFrame(frame))
})
const updateOverflow = () => {
const offset = Math.abs(controlsViewport.scrollLeft)
@@ -113,8 +140,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
const [fadeBottom, setFadeBottom] = createSignal(0)
const updateScroll = () => {
if (!viewport) return
setFadeBottom(Math.min(16, Math.max(0, viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop)))
const scroller = historyLayout.height !== undefined ? content : viewport
if (!scroller) return
setFadeBottom(Math.min(16, Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop)))
}
onMount(() => {
@@ -162,16 +191,19 @@ export function ComposerEditor(props: ComposerEditorProps) {
// Kept out of JSX: an inline ternary prop compiles to a memo created on every read, and the popover reads
// search from a ResizeObserver callback, where that memo has no owner and is never disposed.
const search = () =>
state.popover.type === "command-menu"
? {
value: state.popover.query,
label: i18n.t("ui.promptInput.commands"),
placeholder: "/",
onValueChange: props.controller.setQuery,
onKeyDown: props.controller.onKeyDown,
}
: undefined
const search = () => {
if (state.popover.type !== "history" && state.popover.type !== "command-menu") return
if (state.popover.type === "history" && !state.popover.searching) return
return {
value: state.popover.query,
label: i18n.t(state.popover.type === "history" ? "ui.promptInput.history" : "ui.promptInput.commands"),
placeholder: state.popover.type === "history" ? i18n.t("ui.promptInput.history.search") : "/",
onValueChange: props.controller.setQuery,
onKeyDown: props.controller.onKeyDown,
}
}
return (
<div class={`relative size-full flex flex-col gap-0 ${props.class ?? ""}`}>
@@ -189,21 +221,30 @@ export function ComposerEditor(props: ComposerEditorProps) {
/>
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
<ComposerEditorPopover
id={suggestionsID}
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
boundary={props.suggestionBoundary}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={search()}
history={state.popover.type === "history" ? state.mode : undefined}
historyBrowsing={state.popover.type === "history" && !state.popover.searching}
editor={() => editor}
onSearch={() => props.controller.dispatch({ type: "history.search" })}
onClose={() => props.controller.dispatch({ type: "popover.close" })}
onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })}
onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })}
/>
</Show>
<form
ref={composer}
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative w-full overflow-clip rounded-xl bg-v2-background-bg-base"
style={{ height: historyLayout.height === undefined ? undefined : `${historyLayout.height}px` }}
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"flex flex-col": historyLayout.height !== undefined,
}}
onSubmit={(event) => {
event.preventDefault()
@@ -215,123 +256,153 @@ export function ComposerEditor(props: ComposerEditorProps) {
onDragLeave={props.controller.onDragLeave}
onDrop={props.controller.onDrop}
>
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
files={props.controller.files()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
preview={props.controller.previewAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
onFileRemove={(file) => props.controller.removeContext(file.key)}
editingQuote={props.controller.editingQuote()}
onEditQuote={props.controller.editQuote}
onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)}
searchPaths={props.controller.searchPaths}
/>
</Show>
<ScrollView
data-component="composer-scroll"
class="max-h-[180px]"
<div
ref={content}
data-slot="composer-content"
data-history-preview={historyLayout.height !== undefined ? "" : undefined}
class="min-h-0"
classList={{ "flex-1 overflow-y-auto no-scrollbar": historyLayout.height !== undefined }}
style={{ "--composer-fade-bottom": `${fadeBottom()}px` }}
onScroll={updateScroll}
viewportRef={(element) => {
viewport = element
element.tabIndex = -1
}}
>
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
}}
data-component="composer-editor"
role="textbox"
aria-multiline="true"
aria-label={i18n.t("ui.promptInput.label")}
dir={state.mode === "normal" ? "auto" : "ltr"}
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block w-full whitespace-pre-wrap bg-transparent px-3 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell", "opacity-50": props.disabled }}
style={{
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
"text-align": "start",
}}
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const attachments = props.controller.parts().filter(isAttachment)
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit(mod ? { alternate: true } : undefined)
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={(event) => {
props.controller.onPaste(event)
// Programmatic multiline insertion does not reliably reveal the caret.
requestAnimationFrame(() => {
const selection = window.getSelection()
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
if (!editor.contains(selection.anchorNode)) return
const caret = selection.getRangeAt(0).getBoundingClientRect()
if (!caret.height) return
const bounds = viewport.getBoundingClientRect()
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
})
}}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<Show when={!props.controller.value()}>
<div
dir={state.mode === "normal" ? "auto" : "ltr"}
class="pointer-events-none absolute inset-x-0 top-0 px-3 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell" }}
style={{ "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, "text-align": "start" }}
>
{view.placeholder?.() ??
(state.mode === "shell"
? i18n.t("ui.promptInput.placeholder.shell")
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
</div>
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
files={props.controller.files()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
preview={props.controller.previewAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
onFileRemove={(file) => props.controller.removeContext(file.key)}
editingQuote={props.controller.editingQuote()}
onEditQuote={props.controller.editQuote}
onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)}
searchPaths={props.controller.searchPaths}
/>
</Show>
</ScrollView>
<div class="flex h-11 items-center px-2">
<ScrollView
data-component="composer-scroll"
class={historyLayout.height === undefined ? "max-h-[180px]" : undefined}
onScroll={updateScroll}
viewportRef={(element) => {
viewport = element
element.tabIndex = -1
}}
>
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
}}
data-component="composer-editor"
role="textbox"
aria-multiline="true"
aria-label={i18n.t("ui.promptInput.label")}
aria-controls={state.popover.type === "history" ? suggestionsID : undefined}
aria-activedescendant={
state.popover.type === "history" && !state.popover.searching && state.popover.activeID
? `${suggestionsID}-${state.popover.activeID}`
: undefined
}
dir={state.mode === "normal" ? "auto" : "ltr"}
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block w-full whitespace-pre-wrap bg-transparent px-3 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell", "opacity-50": props.disabled }}
style={{
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
"text-align": "start",
}}
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const attachments = props.controller.parts().filter(isAttachment)
localInput = true
props.controller.onInput(
prompt.map((part) => part.content).join(""),
[...prompt, ...attachments],
cursor,
)
}}
onKeyDown={(event) => {
// Capture the opening footprint before the controller applies its first history preview.
const height =
state.popover.type === "closed" && event.shiftKey && event.key === "ArrowUp"
? composer?.getBoundingClientRect().height
: undefined
if (!view.draftOnly && props.controller.onKeyDown(event)) {
if (height !== undefined && state.popover.type === "history") setHistoryLayout("height", height)
return
}
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit(mod ? { alternate: true } : undefined)
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={(event) => {
props.controller.onPaste(event)
// Programmatic multiline insertion does not reliably reveal the caret.
requestAnimationFrame(() => {
const selection = window.getSelection()
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
if (!editor.contains(selection.anchorNode)) return
const caret = selection.getRangeAt(0).getBoundingClientRect()
if (!caret.height) return
const bounds = viewport.getBoundingClientRect()
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
})
}}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<Show when={!props.controller.value()}>
<div
dir={state.mode === "normal" ? "auto" : "ltr"}
class="pointer-events-none absolute inset-x-0 top-0 px-3 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell" }}
style={{ "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, "text-align": "start" }}
>
{view.placeholder?.() ??
(state.mode === "shell"
? i18n.t("ui.promptInput.placeholder.shell")
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
</div>
</Show>
</ScrollView>
</div>
<div class="flex h-11 shrink-0 items-center px-2">
<div
class="flex shrink-0 items-center"
aria-hidden={state.mode === "shell"}
@@ -1092,9 +1163,15 @@ export function ComposerEditorSelect(props: {
}
export function ComposerEditorPopover(props: {
id?: string
emptyLabel: string
items: ComposerSuggestion[]
activeID?: string
history?: ComposerMode
historyBrowsing?: boolean
editor?: () => HTMLElement | undefined
onSearch?: () => void
onClose?: () => void
search?: {
value: string
label: string
@@ -1106,10 +1183,44 @@ export function ComposerEditorPopover(props: {
onActiveChange: (item: ComposerSuggestion) => void
onSelect: (item: ComposerSuggestion) => void
}) {
const i18n = useI18n()
const listID = props.id ?? createUniqueId()
let container: HTMLDivElement | undefined
const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT })
const resize = (height: number) =>
setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined))
setStore(
"maxHeight",
composerSuggestionMaxHeight(height, props.search !== undefined || !!props.history, !!props.history),
)
createEffect(() => {
if (!props.history || !props.activeID) return
const frame = requestAnimationFrame(() =>
container?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }),
)
onCleanup(() => cancelAnimationFrame(frame))
})
createEffect(() => {
if (!props.history) return
const dismiss = (event: Event) => {
if (!(event.target instanceof Node) || container?.contains(event.target)) return
if (event.type === "focusin" && props.historyBrowsing && props.editor?.()?.contains(event.target)) return
props.onClose?.()
}
document.addEventListener("pointerdown", dismiss)
document.addEventListener("focusin", dismiss)
onCleanup(() => {
document.removeEventListener("pointerdown", dismiss)
document.removeEventListener("focusin", dismiss)
})
})
// A detached boundary (e.g. the previous session's timeline while the next one loads) measures 0px.
const boundary = () => {
@@ -1123,75 +1234,155 @@ export function ComposerEditorPopover(props: {
return (
<div
ref={container}
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
classList={{ "overflow-auto": !props.history, "overflow-hidden": !!props.history }}
style={{ "max-height": `${store.maxHeight}px` }}
onMouseDown={(event) => event.preventDefault()}
>
<Show when={props.search}>
{(search) => (
<div class="shrink-0 px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.focus())}
value={search().value}
aria-label={search().label}
placeholder={search().placeholder}
class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => search().onValueChange(event.currentTarget.value)}
onKeyDown={(event) => search().onKeyDown(event)}
onMouseDown={(event) => event.stopPropagation()}
/>
</div>
)}
</Show>
<Show
when={props.items.length > 0}
fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
>
<For each={props.items}>
{(item) => (
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<ComposerSuggestionIcon item={item} />
<bdi dir="auto" class="shrink-0 text-v2-text-text-base">
{item.label}
</bdi>
<Show when={item.description}>
<span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
</Show>
</div>
<Show when={item.keybind?.length}>
<span class="shrink-0 text-v2-text-text-muted">{item.keybind?.join("+")}</span>
</Show>
</button>
<Show when={props.history}>
<Show when={props.search}>
{(search) => (
<div class="shrink-0 px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.isConnected && element.focus())}
value={search().value}
aria-label={search().label}
placeholder={search().placeholder}
autocomplete="off"
spellcheck={false}
class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
role="combobox"
aria-expanded="true"
aria-autocomplete="list"
aria-controls={listID}
aria-activedescendant={props.activeID ? `${listID}-${props.activeID}` : undefined}
onInput={(event) => search().onValueChange(event.currentTarget.value)}
onKeyDown={(event) => search().onKeyDown(event)}
onMouseDown={(event) => event.stopPropagation()}
/>
</div>
)}
</For>
</Show>
<Show when={props.historyBrowsing}>
<button
type="button"
class="flex h-7 shrink-0 items-center justify-between gap-2 rounded-md px-2 text-[13px] leading-4 text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.onSearch}
>
<span>{i18n.t("ui.promptInput.history.searchAction")}</span>
<Keybind keys={formatKeybindParts("mod+f")} variant="ghost" />
</button>
</Show>
<ScrollView class="min-h-0 flex-1">
<div id={listID} role="listbox" aria-label={i18n.t("ui.promptInput.history")} class="flex flex-col">
<Show
when={props.items.length}
fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
>
<For each={props.items}>
{(item) => (
<button
id={`${listID}-${item.id}`}
type="button"
role="option"
aria-selected={props.activeID === item.id}
tabindex={-1}
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="palette-menu-row"
data-size="compact"
onPointerMove={(event) => {
if (event.movementX || event.movementY) props.onActiveChange(item)
}}
onClick={() => props.onSelect(item)}
>
<bdi
dir={props.history === "shell" ? "ltr" : "auto"}
class="palette-menu-title flex-1 text-start"
style={
props.history === "shell"
? { "font-family": "var(--font-family-mono)", "font-weight": 440 }
: undefined
}
>
{item.label || item.description || i18n.t("ui.promptInput.history.contextOnly")}
</bdi>
<Show when={item.description}>
<Icon name="file-document" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Show>
</button>
)}
</For>
</Show>
</div>
</ScrollView>
</Show>
<Show when={!props.history}>
<Show when={props.search}>
{(search) => (
<div class="shrink-0 px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.focus())}
value={search().value}
aria-label={search().label}
placeholder={search().placeholder}
class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => search().onValueChange(event.currentTarget.value)}
onKeyDown={(event) => search().onKeyDown(event)}
onMouseDown={(event) => event.stopPropagation()}
/>
</div>
)}
</Show>
<Show
when={props.items.length > 0}
fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
>
<For each={props.items}>
{(item) => (
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<ComposerSuggestionIcon item={item} />
<bdi dir="auto" class="shrink-0 text-v2-text-text-base">
{item.label}
</bdi>
<Show when={item.description}>
<span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
</Show>
</div>
<Show when={item.keybind?.length}>
<span class="shrink-0 text-v2-text-text-muted">{item.keybind?.join("+")}</span>
</Show>
</button>
)}
</For>
</Show>
</Show>
</div>
)
}
function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean, history = false) {
const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4)
const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve)
const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
// History has padding on both edges and shows whole rows below the pinned search control.
const chrome = COMPOSER_SUGGESTION_TOP_PADDING * (history ? 2 : 1) + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
const row = COMPOSER_SUGGESTION_ROW_HEIGHT
const peek = history ? 0 : COMPOSER_SUGGESTION_ROW_PEEK
if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit
if (limit < chrome + row + peek) return limit
return (
chrome +
Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) *
COMPOSER_SUGGESTION_ROW_HEIGHT +
COMPOSER_SUGGESTION_ROW_PEEK
)
return chrome + Math.floor((limit - chrome - peek) / row) * row + peek
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
+160 -31
View File
@@ -1,5 +1,5 @@
import { Predicate } from "effect"
import { createEffect, createSignal, type Accessor } from "solid-js"
import { createEffect, createMemo, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode/ui/hooks"
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
@@ -80,10 +80,35 @@ export function createComposerEditor(input: {
}) {
let editor: HTMLElement | undefined
let fileInput: HTMLInputElement | undefined
let focusFrame: number | undefined
let focusCursor: number | undefined
const cancelFocus = () => {
if (focusFrame === undefined) return
cancelAnimationFrame(focusFrame)
focusFrame = undefined
focusCursor = undefined
}
const flushFocus = () => {
const cursor = focusCursor
if (cursor === undefined) return
cancelFocus()
editor?.focus()
setEditorCursor(editor, cursor)
}
onCleanup(cancelFocus)
const draft = createComposerEditorActions(input.store)
const [localQuoteComment, setLocalQuoteComment] = createSignal<string>()
const quoteComment = input.quoteComment ?? { key: localQuoteComment, set: setLocalQuoteComment }
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
const [savedHistory, setSavedHistory] = createStore<{ entry?: ComposerHistoryEntry; cursor?: number }>({})
const [cycle, setCycle] = createStore<{ index: number; entry?: ComposerHistoryEntry; cursor?: number }>({ index: -1 })
const resetCycle = () => setCycle({ index: -1, entry: undefined, cursor: undefined })
createEffect(on([() => draft.state, () => state.mode], resetCycle, { defer: true }))
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (isAttachment(part)) return false
@@ -115,6 +140,8 @@ export function createComposerEditor(input: {
: undefined
const attach = () => {
resetCycle()
if (!attachments) {
input.view.add?.onAttach()
@@ -174,9 +201,42 @@ export function createComposerEditor(input: {
})
const list = () => (state.popover.type === "context" ? contextList : commandList)
const suggestions = () => list().flat()
const history = createMemo<ComposerSuggestion[]>(() =>
(input.history?.entries(state.mode) ?? []).map((entry, index) => ({
id: `history:${index}`,
kind: "history",
label: entry.prompt.map((part) => ("content" in part ? part.content : "")).join(""),
description: entry.prompt.flatMap((part) => (isAttachment(part) ? [part.filename] : [])).join(", "),
history: entry,
})),
)
const suggestions = () => {
if (state.popover.type !== "history") return list().flat()
const query = state.popover.query.trim().toLowerCase()
return history()
.filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(query))
.toReversed()
}
const execute = (command: ComposerInteractionCommand) => {
if (command.type === "history.preview") {
const item = history().find((entry) => entry.id === command.id)
if (item?.history)
applyHistory(item.history, { focus: state.popover.type === "history" && !state.popover.searching })
return
}
if (command.type === "history.restore") {
if (savedHistory.entry) applyHistory(savedHistory.entry, { cursor: savedHistory.cursor })
return
}
if (command.type === "draft.setText") {
draft.setText(command.value)
@@ -209,10 +269,32 @@ export function createComposerEditor(input: {
return
}
if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus())
if (command.type === "focus.editor") {
if (state.popover.type === "history") return restoreFocus()
requestAnimationFrame(() => editor?.focus())
}
}
function dispatch(event: ComposerInteractionEvent) {
if (event.type === "history.search" || event.type === "popover.close") cancelFocus()
if (
event.type === "history.open" ||
event.type === "commands.open" ||
event.type === "context.open" ||
event.type === "popover.select"
)
resetCycle()
if (event.type === "popover.select" && event.item.kind === "history") {
if (!event.item.history) return false
setState({ popover: { type: "closed" }, focus: "editor" })
applyHistory(event.item.history)
setSavedHistory({ entry: undefined, cursor: undefined })
return true
}
const mode = state.mode
const result = transitionComposer(state, event, draft.state)
const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined
@@ -231,6 +313,8 @@ export function createComposerEditor(input: {
if (event.type !== "popover.select") result.commands.forEach(execute)
if (state.popover.type !== "history") setSavedHistory({ entry: undefined, cursor: undefined })
if (mode !== result.state.mode) {
if (result.state.mode === "shell") input.view.shell?.onOpen()
@@ -246,6 +330,23 @@ export function createComposerEditor(input: {
}
const onKeyDown = (event: KeyboardEvent) => {
flushFocus()
if (
state.popover.type === "history" &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
!event.isComposing &&
event.key.toLowerCase() === "f"
) {
event.preventDefault()
event.stopPropagation()
dispatch({ type: "history.search" })
return true
}
if (
state.mode === "normal" &&
(event.metaKey || event.ctrlKey) &&
@@ -302,8 +403,11 @@ export function createComposerEditor(input: {
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.isComposing &&
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
navigateHistory(event.key === "ArrowUp" ? "up" : "down")
(event.shiftKey
? event.key === "ArrowUp" && openHistory()
: navigateHistory(event.key === "ArrowUp" ? "up" : "down"))
) {
event.preventDefault()
@@ -322,59 +426,74 @@ export function createComposerEditor(input: {
})
const restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => {
requestAnimationFrame(() => {
editor?.focus()
setEditorCursor(editor, cursor)
})
cancelFocus()
focusCursor = cursor
focusFrame = requestAnimationFrame(flushFocus)
}
const applyHistory = (entry: ComposerHistoryEntry, position: "start" | "end") => {
const applyHistory = (entry: ComposerHistoryEntry, options?: { focus?: boolean; cursor?: number }) => {
input.history?.restore?.(entry.metadata)
const cursor = position === "start" ? 0 : promptLength(entry.prompt)
const cursor = options?.cursor ?? promptLength(entry.prompt)
draft.setPrompt(clonePrompt(entry.prompt), cursor)
restoreFocus(cursor)
if (options?.focus !== false) restoreFocus(cursor)
}
const openHistory = () => {
if (state.popover.type !== "closed" || !editor || !history().length) return false
const selection = window.getSelection()
if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
if (draft.state.prompt.some((part) => "content" in part && part.content.length > 0)) return false
setSavedHistory({
entry: { prompt: clonePrompt(draft.state.prompt), metadata: input.history?.capture?.() },
cursor: draft.state.cursor,
})
dispatch({ type: "history.open", id: history()[0]!.id })
return true
}
const navigateHistory = (direction: "up" | "down") => {
if (!input.history || !editor) return false
if (state.popover.type !== "closed" || !input.history || !editor) return false
const selection = window.getSelection()
if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false
const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false
if (!canNavigateHistory(direction, text, editorCursor(editor), cycle.index >= 0)) return false
const entries = input.history.entries(state.mode)
if (direction === "up") {
if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false
if (entries.length === 0 || cycle.index >= entries.length - 1) return false
if (state.historyIndex === -1) {
setState("savedHistory", {
prompt: clonePrompt(draft.state.prompt),
metadata: input.history.capture?.(),
if (cycle.index === -1) {
setCycle({
entry: { prompt: clonePrompt(draft.state.prompt), metadata: input.history.capture?.() },
cursor: draft.state.cursor,
})
}
const index = state.historyIndex + 1
setState("historyIndex", index)
applyHistory(entries[index]!, "start")
const index = cycle.index + 1
setCycle("index", index)
applyHistory(entries[index]!, { cursor: 0 })
return true
}
if (state.historyIndex < 0) return false
if (cycle.index < 0 || !cycle.entry) return false
if (state.historyIndex > 0) {
const index = state.historyIndex - 1
setState("historyIndex", index)
applyHistory(entries[index]!, "end")
if (cycle.index > 0) {
const index = cycle.index - 1
setCycle("index", index)
applyHistory(entries[index]!)
return true
}
const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] }
setState({ historyIndex: -1, savedHistory: undefined })
applyHistory(saved, "end")
applyHistory(cycle.entry, { cursor: cycle.cursor })
resetCycle()
return true
}
@@ -424,6 +543,7 @@ export function createComposerEditor(input: {
return (await input.searchContextFiles(query)).flatMap((item) => (item.path ? [item.path] : []))
},
removeContext(id: string) {
resetCycle()
const item = draft.state.context.items.find((entry) => entry.key === id)
if (item) input.onContextRemove?.(item)
@@ -443,6 +563,7 @@ export function createComposerEditor(input: {
return input.readImage?.(attachment.path) ?? Promise.resolve(undefined)
},
removeAttachment(id: string) {
resetCycle()
draft.removeAttachment(id)
},
canSubmit() {
@@ -469,6 +590,9 @@ export function createComposerEditor(input: {
},
restoreFocus,
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
cancelFocus()
resetCycle()
if (prompt) draft.setPrompt(prompt, cursor)
if (input.view.draftOnly) return
@@ -492,6 +616,7 @@ export function createComposerEditor(input: {
if (input.view.draftOnly) return
if (attachments?.pending().length) return
resetCycle()
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
@@ -499,17 +624,20 @@ export function createComposerEditor(input: {
input.view.submit.onStop()
},
addHistory(prompt: ComposerPersistedState["prompt"], mode: "normal" | "shell") {
resetCycle()
input.history?.add(prompt, mode)
setState({ historyIndex: -1, savedHistory: undefined })
},
resetHistory() {
setState({ historyIndex: -1, savedHistory: undefined })
resetCycle()
if (state.popover.type === "history") dispatch({ type: "popover.close" })
},
onPaste(event: ClipboardEvent) {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain")
if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) {
resetCycle()
void attachments.handlePaste(event)
return
@@ -572,6 +700,7 @@ export function createComposerEditor(input: {
},
addAttachments(files: File[]) {
if (!attachments) return
resetCycle()
void attachments.addAttachments(files)
restoreFocus()
},
+9
View File
@@ -528,6 +528,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
Object.defineProperty(controller, "model", { get: () => adapter.controls().model })
command.register("composer-editor", () => [
{
id: "prompt.history.search",
title: language.t("ui.promptInput.history.search"),
keybind: "mod+f",
hidden: true,
editable: true,
when: () => controller.state.popover.type === "history",
onSelect: () => void controller.dispatch({ type: "history.search" }),
},
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
@@ -1,4 +1,4 @@
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
import type { ComposerPersistedState, ComposerSuggestion } from "../types"
import { isAttachment } from "../prompt-parts"
export type ComposerInteractionState = {
@@ -8,17 +8,19 @@ export type ComposerInteractionState = {
| { type: "context"; query: string; activeID?: string }
| { type: "command-inline"; query: string; activeID?: string }
| { type: "command-menu"; query: string; activeID?: string }
| { type: "history"; query: string; searching: boolean; activeID?: string }
drag: "idle" | "active"
focus: "editor" | "command-search" | "external"
focus: "editor" | "command-search" | "history-search" | "external"
activeContextID?: string
historyIndex: number
savedHistory?: ComposerHistoryEntry
}
export type ComposerInteractionEvent =
| { type: "input.changed"; value: string; persist?: boolean }
| { type: "commands.open" }
| { type: "context.open" }
| { type: "history.open"; id: string }
| { type: "history.search" }
| { type: "history.browse" }
| { type: "popover.query"; value: string }
| { type: "popover.results"; ids: string[] }
| { type: "popover.active"; id: string }
@@ -41,6 +43,8 @@ export type ComposerInteractionCommand =
| { type: "suggestion.select"; id: string }
| { type: "focus.editor" }
| { type: "focus.command-search" }
| { type: "history.preview"; id: string }
| { type: "history.restore" }
export type ComposerEditorTransition = {
state: ComposerInteractionState
@@ -54,7 +58,6 @@ export function createComposerInteractionState(): ComposerInteractionState {
popover: { type: "closed" },
drag: "idle",
focus: "external",
historyIndex: -1,
}
}
@@ -69,6 +72,17 @@ export function transitionComposer(
if (event.type === "context.open") return openContext(state)
if (event.type === "history.open")
return changed(
{ ...state, popover: { type: "history", query: "", searching: false, activeID: event.id }, focus: "editor" },
[{ type: "history.preview", id: event.id }],
)
if (event.type === "history.search" && state.popover.type === "history")
return changed({ ...state, popover: { ...state.popover, searching: true }, focus: "history-search" })
if (event.type === "history.browse") return browseHistory(state)
if (event.type === "popover.query") return queryChanged(state, event.value)
if (event.type === "popover.results") return resultsChanged(state, event.ids)
@@ -165,6 +179,10 @@ function openContext(state: ComposerInteractionState): ComposerEditorTransition
function queryChanged(state: ComposerInteractionState, query: string): ComposerEditorTransition {
if (state.popover.type === "closed") return unchanged(state)
if (state.popover.type === "history")
return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } })
const popover = state.popover.type === "context" ? "context" : "command"
return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [
@@ -172,19 +190,41 @@ function queryChanged(state: ComposerInteractionState, query: string): ComposerE
])
}
function browseHistory(state: ComposerInteractionState): ComposerEditorTransition {
if (state.popover.type !== "history") return unchanged(state)
return changed(
{ ...state, popover: { ...state.popover, searching: false, query: "" }, focus: "editor" },
[{ type: "focus.editor" }],
true,
)
}
function resultsChanged(state: ComposerInteractionState, ids: string[]): ComposerEditorTransition {
if (state.popover.type === "closed") return unchanged(state)
const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0]
const activeID =
state.popover.activeID && ids.includes(state.popover.activeID)
? state.popover.activeID
: state.popover.type === "history"
? ids.at(-1)
: ids[0]
if (activeID === state.popover.activeID) return unchanged(state)
return changed({ ...state, popover: { ...state.popover, activeID } })
return changed(
{ ...state, popover: { ...state.popover, activeID } },
state.popover.type === "history" && activeID ? [{ type: "history.preview", id: activeID }] : [],
)
}
function activeChanged(state: ComposerInteractionState, id: string): ComposerEditorTransition {
if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state)
return changed({ ...state, popover: { ...state.popover, activeID: id } })
return changed(
{ ...state, popover: { ...state.popover, activeID: id } },
state.popover.type === "history" ? [{ type: "history.preview", id }] : [],
)
}
function suggestionSelected(
@@ -224,6 +264,12 @@ function keyDown(
state: ComposerInteractionState,
event: Extract<ComposerInteractionEvent, { type: "key.down" }>,
): ComposerEditorTransition {
if (state.popover.type === "history" && (event.key === "Escape" || (event.ctrl && event.key.toLowerCase() === "g"))) {
if (state.popover.searching && event.key === "Escape") return browseHistory(state)
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "history.restore" }], true)
}
if (event.ctrl && event.key.toLowerCase() === "g") {
if (state.popover.type === "closed") return unchanged(state)
@@ -238,7 +284,7 @@ function keyDown(
return unchanged(state)
}
if (event.key === "Escape") {
if (event.key === "Escape" || (state.popover.type === "history" && event.key === "Tab")) {
return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true)
}
@@ -265,7 +311,16 @@ function keyDown(
: event.ids.length - 1
: (current + direction + event.ids.length) % event.ids.length
return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true)
const next =
state.popover.type === "history" && current >= 0
? Math.max(0, Math.min(event.ids.length - 1, current + direction))
: index
return changed(
{ ...state, popover: { ...state.popover, activeID: event.ids[next] } },
state.popover.type === "history" ? [{ type: "history.preview", id: event.ids[next] }] : [],
true,
)
}
function promptText(persisted: ComposerPersistedState) {
+2 -1
View File
@@ -47,7 +47,7 @@ export type ComposerOption = {
export type ComposerSuggestion = {
id: string
kind: "agent" | "command" | "file" | "reference" | "resource" | "skill" | "session"
kind: "agent" | "command" | "file" | "reference" | "resource" | "skill" | "session" | "history"
label: string
title?: string
trigger?: string
@@ -55,5 +55,6 @@ export type ComposerSuggestion = {
path?: string
keybind?: string[]
recent?: boolean
history?: ComposerHistoryEntry
mention?: ComposerFilePart | ComposerAgentPart | ComposerSkillPart | ComposerSessionPart
}
@@ -28,44 +28,6 @@
padding: 0;
}
.command-palette-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.command-palette-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-scroll {
min-height: 0;
flex: 1;
@@ -95,37 +57,6 @@
user-select: none;
}
.command-palette-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: default;
scroll-margin: 6px 0;
}
.command-palette-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
[data-color-scheme="dark"] .command-palette-row:is([data-active], :focus-visible) {
background: var(--v2-alpha-light-10);
}
.command-palette-row-main {
display: flex;
min-width: 0;
@@ -146,18 +77,6 @@
gap: 6px;
}
.command-palette-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-description,
.command-palette-meta {
min-width: 0;
+8 -4
View File
@@ -16,6 +16,7 @@ import { getRelativeTime } from "@/shell/time"
import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette"
import { createCommandPaletteSearch, matchesCommandPaletteEntry } from "./search"
import "./dialog.css"
import "../palette-menu.css"
export { matchesCommandPaletteEntry }
@@ -115,7 +116,7 @@ export function CommandPaletteView(props: {
return (
<Dialog class="command-palette" size="large">
<DialogBody class="command-palette-body">
<div class="command-palette-search">
<div class="palette-menu-search">
<TextInput
value={store.query}
autofocus
@@ -215,7 +216,7 @@ function PaletteRow(props: {
<button
id={props.id}
type="button"
class="command-palette-row group"
class="command-palette-row palette-menu-row group"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
@@ -241,7 +242,7 @@ function PaletteRow(props: {
<Match when={props.item.type === "command" || props.item.type === "setting"}>
<div class="command-palette-row-main">
<div class="command-palette-row-text">
<span class="command-palette-title">{props.item.title}</span>
<span class="command-palette-title palette-menu-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-description">{props.item.description}</span>
</Show>
@@ -273,7 +274,10 @@ function PaletteRow(props: {
</Show>
</div>
<div class="command-palette-row-text">
<span class="command-palette-title" classList={{ "opacity-70": !!props.item.archived }}>
<span
class="command-palette-title palette-menu-title"
classList={{ "opacity-70": !!props.item.archived }}
>
{props.item.title}
</span>
<Show when={props.item.description}>
+88
View File
@@ -0,0 +1,88 @@
.palette-menu-search {
flex-shrink: 0;
padding: 6px;
}
.palette-menu-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.palette-menu-search
[data-component="text-input-v2"]:where(:hover, :focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.palette-menu-search [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.palette-menu-search [data-slot="text-input-v2-leading-icon"] {
padding-inline-start: 12px;
}
.palette-menu-search [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.palette-menu-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: start;
cursor: default;
scroll-margin: 6px 0;
}
.palette-menu-row:is([data-active], :focus-visible) {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.palette-menu-row[data-size="compact"] {
height: 28px;
padding-inline: 8px;
scroll-margin-block: 0;
}
[data-color-scheme="dark"] .palette-menu-row:is([data-active], :focus-visible) {
background: var(--v2-alpha-light-10);
}
.palette-menu-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.palette-menu-search [data-component="text-input-v2"] {
transition: none;
}
}
+4
View File
@@ -150,6 +150,10 @@ const source = {
"ui.promptInput.noMatchingItems": "No matching items",
"ui.promptInput.commands": "Commands",
"ui.promptInput.history": "Prompt history",
"ui.promptInput.history.search": "Search prompt history…",
"ui.promptInput.history.searchAction": "Search",
"ui.promptInput.history.contextOnly": "Prompt with context",
"ui.promptInput.dropFiles": "Drop files to add",
"ui.promptInput.dropFiles.image": "Drop images or files to add",
"ui.promptInput.dropFiles.pdf": "Drop PDFs or files to add",