mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 864fc42e0a | |||
| b1491690cd |
@@ -66,6 +66,7 @@ export type PromptProps = {
|
||||
sessionID?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
inert?: boolean
|
||||
onSubmit?: () => void
|
||||
ref?: (ref: PromptRef | undefined) => void
|
||||
hint?: JSX.Element
|
||||
@@ -700,7 +701,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.visible === false || dialog.stack.length > 0) {
|
||||
if (props.visible === false || dialog.stack.length > 0 || props.inert) {
|
||||
if (input.focused) input.blur()
|
||||
return
|
||||
}
|
||||
@@ -1388,7 +1389,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
if (leader()) return theme.border
|
||||
if (leader() || props.inert) return theme.border
|
||||
if (store.mode === "shell") return theme.primary
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return theme.border
|
||||
@@ -1494,8 +1495,8 @@ export function Prompt(props: PromptProps) {
|
||||
width="100%"
|
||||
placeholder={placeholderText()}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={leader() ? theme.textMuted : theme.text}
|
||||
focusedTextColor={leader() ? theme.textMuted : theme.text}
|
||||
textColor={leader() || props.inert ? theme.textMuted : theme.text}
|
||||
focusedTextColor={leader() || props.inert ? theme.textMuted : theme.text}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={() => {
|
||||
|
||||
@@ -142,6 +142,14 @@ export const Definitions = {
|
||||
messages_undo: keybind("<leader>u", "Undo message"),
|
||||
messages_redo: keybind("<leader>r", "Redo message"),
|
||||
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
|
||||
messages_focus_toggle: keybind("none", "Toggle keyboard focus on user messages"),
|
||||
messages_focus_previous: keybind("up,k", "Focus the previous user message"),
|
||||
messages_focus_next: keybind("down,j", "Focus the next user message"),
|
||||
messages_focus_actions: keybind("return", "Open actions for the focused message"),
|
||||
messages_focus_revert: keybind("r", "Revert to the focused message"),
|
||||
messages_focus_copy: keybind("c", "Copy the focused message"),
|
||||
messages_focus_fork: keybind("f", "Fork from the focused message"),
|
||||
messages_focus_exit: keybind("i", "Exit user message focus"),
|
||||
tool_details: keybind("none", "Toggle tool details visibility"),
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
@@ -337,6 +345,14 @@ export const CommandMap = {
|
||||
messages_undo: "session.undo",
|
||||
messages_redo: "session.redo",
|
||||
messages_toggle_conceal: "session.toggle.conceal",
|
||||
messages_focus_toggle: "session.message.focus.toggle",
|
||||
messages_focus_previous: "session.message.focus.previous",
|
||||
messages_focus_next: "session.message.focus.next",
|
||||
messages_focus_actions: "session.message.focus.actions",
|
||||
messages_focus_revert: "session.message.focus.revert",
|
||||
messages_focus_copy: "session.message.focus.copy",
|
||||
messages_focus_fork: "session.message.focus.fork",
|
||||
messages_focus_exit: "session.message.focus.exit",
|
||||
tool_details: "session.toggle.actions",
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
import { MessageActions } from "./message-actions"
|
||||
|
||||
export function DialogMessage(props: {
|
||||
messageID: string
|
||||
@@ -26,29 +26,14 @@ export function DialogMessage(props: {
|
||||
value: "session.revert",
|
||||
description: "undo messages and file changes",
|
||||
onSelect: (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
|
||||
void sdk.client.session.revert({
|
||||
if (!message()) return
|
||||
MessageActions.revert({
|
||||
sdk,
|
||||
sync,
|
||||
sessionID: props.sessionID,
|
||||
messageID: msg.id,
|
||||
messageID: props.messageID,
|
||||
setPrompt: props.setPrompt,
|
||||
})
|
||||
|
||||
if (props.setPrompt) {
|
||||
const parts = sync.data.part[msg.id]
|
||||
const promptInfo = parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(strip(part))
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
props.setPrompt(promptInfo)
|
||||
}
|
||||
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -57,18 +42,8 @@ export function DialogMessage(props: {
|
||||
value: "message.copy",
|
||||
description: "message text to clipboard",
|
||||
onSelect: async (dialog) => {
|
||||
const msg = message()
|
||||
if (!msg) return
|
||||
|
||||
const parts = sync.data.part[msg.id]
|
||||
const text = parts.reduce((agg, part) => {
|
||||
if (part.type === "text" && !part.synthetic) {
|
||||
agg += part.text
|
||||
}
|
||||
return agg
|
||||
}, "")
|
||||
|
||||
await Clipboard.copy(text)
|
||||
if (!message()) return
|
||||
await Clipboard.copy(MessageActions.collectText(sync, props.messageID))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -77,28 +52,14 @@ export function DialogMessage(props: {
|
||||
value: "session.fork",
|
||||
description: "create a new session",
|
||||
onSelect: async (dialog) => {
|
||||
const result = await sdk.client.session.fork({
|
||||
if (!message()) return
|
||||
await MessageActions.fork({
|
||||
sdk,
|
||||
sync,
|
||||
navigate: route.navigate,
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
})
|
||||
const msg = message()
|
||||
const prompt = msg
|
||||
? sync.data.part[msg.id].reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
: undefined
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
type: "session",
|
||||
prompt,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
@@ -57,6 +58,7 @@ import { useDialog } from "../../ui/dialog"
|
||||
import { DialogAlert } from "../../ui/dialog-alert"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { MessageActions } from "./message-actions"
|
||||
import type { PromptInfo } from "../../component/prompt/history"
|
||||
import { DialogConfirm } from "@tui/ui/dialog-confirm"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
@@ -89,7 +91,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||
import { SessionRetry } from "@/session/retry"
|
||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap, useOpencodeModeStack } from "../../keymap"
|
||||
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
@@ -118,6 +120,8 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
|
||||
}
|
||||
}
|
||||
|
||||
const FOCUS_MODE = "messages"
|
||||
|
||||
const sessionBindingCommands = [
|
||||
"session.share",
|
||||
"session.rename",
|
||||
@@ -1109,8 +1113,202 @@ export function Session() {
|
||||
}
|
||||
})
|
||||
|
||||
const modeStack = useOpencodeModeStack()
|
||||
const [navMode, setNavMode] = createSignal(false)
|
||||
const [focusedMessage, setFocusedMessage] = createSignal<string>()
|
||||
|
||||
const navigableMessages = createMemo(() => {
|
||||
const revertID = revertMessageID()
|
||||
return messages().filter((message): message is UserMessage => {
|
||||
if (message.role !== "user") return false
|
||||
if (revertID && message.id >= revertID) return false
|
||||
return (sync.data.part[message.id] ?? []).some(
|
||||
(part) => part.type === "text" && !part.synthetic && !part.ignored,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
let scrollAnimation: ReturnType<typeof setInterval> | undefined
|
||||
function cancelScrollAnimation() {
|
||||
if (!scrollAnimation) return
|
||||
clearInterval(scrollAnimation)
|
||||
scrollAnimation = undefined
|
||||
}
|
||||
|
||||
function scrollToFocused(messageID: string) {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
const child = scroll.getChildren().find((c) => c.id === messageID)
|
||||
if (!child) return
|
||||
|
||||
const desiredTop = Math.max(1, Math.floor((scroll.height - child.height) / 2))
|
||||
const target = scroll.scrollTop + (child.y - scroll.y) - desiredTop
|
||||
cancelScrollAnimation()
|
||||
if (!kv.get("animations_enabled", true)) {
|
||||
scroll.scrollTo(target)
|
||||
return
|
||||
}
|
||||
scrollAnimation = setInterval(() => {
|
||||
if (!scroll || scroll.isDestroyed) return cancelScrollAnimation()
|
||||
const remaining = target - scroll.scrollTop
|
||||
if (Math.abs(remaining) <= 1) {
|
||||
scroll.scrollTo(target)
|
||||
cancelScrollAnimation()
|
||||
} else {
|
||||
scroll.scrollTo(scroll.scrollTop + remaining * 0.3)
|
||||
}
|
||||
renderer.requestRender()
|
||||
}, 16)
|
||||
}
|
||||
|
||||
function focusEnter() {
|
||||
if (!visible()) return false
|
||||
if (navigableMessages().length === 0) return false
|
||||
setNavMode(true)
|
||||
}
|
||||
|
||||
function focusExit() {
|
||||
if (!navMode()) return
|
||||
cancelScrollAnimation()
|
||||
setFocusedMessage(undefined)
|
||||
setNavMode(false)
|
||||
toBottom()
|
||||
}
|
||||
|
||||
function focusMessage(messageID: string) {
|
||||
setFocusedMessage(messageID)
|
||||
scrollToFocused(messageID)
|
||||
}
|
||||
|
||||
function focusMove(direction: 1 | -1) {
|
||||
const list = navigableMessages()
|
||||
if (list.length === 0) return focusExit()
|
||||
const current = focusedMessage()
|
||||
if (current === undefined) {
|
||||
if (direction === -1) return focusMessage(list[list.length - 1].id)
|
||||
return focusExit()
|
||||
}
|
||||
const index = list.findIndex((message) => message.id === current)
|
||||
const next = index === -1 ? list.length - 1 : index + direction
|
||||
if (next < 0) return
|
||||
if (next >= list.length) return focusExit()
|
||||
focusMessage(list[next].id)
|
||||
}
|
||||
|
||||
function withFocused(action: (messageID: string) => void) {
|
||||
const messageID = focusedMessage()
|
||||
if (messageID) action(messageID)
|
||||
}
|
||||
|
||||
const focusCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
name: "session.message.focus.toggle",
|
||||
title: "Toggle message focus",
|
||||
run: () => (navMode() ? focusExit() : focusEnter()),
|
||||
},
|
||||
{ name: "session.message.focus.exit", title: "Exit message focus", run: focusExit },
|
||||
{ name: "session.message.focus.previous", title: "Focus previous message", run: () => focusMove(-1) },
|
||||
{ name: "session.message.focus.next", title: "Focus next message", run: () => focusMove(1) },
|
||||
{
|
||||
name: "session.message.focus.actions",
|
||||
title: "Message actions",
|
||||
run: () =>
|
||||
withFocused((messageID) =>
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={messageID}
|
||||
sessionID={route.sessionID}
|
||||
// Revert refills the prompt; exit focus so the draft is editable.
|
||||
setPrompt={(info) => {
|
||||
prompt?.set(info)
|
||||
focusExit()
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "session.message.focus.revert",
|
||||
title: "Revert to message",
|
||||
run: () =>
|
||||
withFocused((messageID) => {
|
||||
MessageActions.revert({
|
||||
sdk,
|
||||
sync,
|
||||
sessionID: route.sessionID,
|
||||
messageID,
|
||||
setPrompt: (info) => prompt?.set(info),
|
||||
})
|
||||
focusExit()
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "session.message.focus.copy",
|
||||
title: "Copy message",
|
||||
run: () =>
|
||||
withFocused((messageID) => {
|
||||
const text = MessageActions.collectText(sync, messageID)
|
||||
if (!text) return
|
||||
void Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
|
||||
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "session.message.focus.fork",
|
||||
title: "Fork from message",
|
||||
run: () =>
|
||||
withFocused((messageID) => void MessageActions.fork({ sdk, sync, navigate, sessionID: route.sessionID, messageID })),
|
||||
},
|
||||
].map((command) => ({ namespace: "palette", hidden: true, category: "Session", ...command })),
|
||||
)
|
||||
|
||||
useBindings(() => ({ commands: focusCommands() }))
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("messages.focus.enter", ["session.message.focus.toggle"]),
|
||||
}))
|
||||
useBindings(() => ({
|
||||
mode: FOCUS_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("messages.focus", [
|
||||
"session.message.focus.toggle",
|
||||
"session.message.focus.exit",
|
||||
"session.message.focus.previous",
|
||||
"session.message.focus.next",
|
||||
"session.message.focus.actions",
|
||||
"session.message.focus.revert",
|
||||
"session.message.focus.copy",
|
||||
"session.message.focus.fork",
|
||||
]),
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (!navMode()) return
|
||||
const dispose = modeStack.push(FOCUS_MODE)
|
||||
onCleanup(dispose)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!navMode()) return
|
||||
const current = focusedMessage()
|
||||
if (!visible() || (current !== undefined && !navigableMessages().some((message) => message.id === current)))
|
||||
focusExit()
|
||||
})
|
||||
|
||||
onCleanup(cancelScrollAnimation)
|
||||
|
||||
// snap to bottom when session changes
|
||||
createEffect(on(() => route.sessionID, toBottom))
|
||||
createEffect(
|
||||
on(
|
||||
() => route.sessionID,
|
||||
() => {
|
||||
cancelScrollAnimation()
|
||||
setNavMode(false)
|
||||
setFocusedMessage(undefined)
|
||||
toBottom()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<PathFormatterProvider path={session()?.directory}>
|
||||
@@ -1148,7 +1346,7 @@ export function Session() {
|
||||
foregroundColor: theme.border,
|
||||
},
|
||||
}}
|
||||
stickyScroll={true}
|
||||
stickyScroll={!navMode()}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
@@ -1223,6 +1421,8 @@ export function Session() {
|
||||
<Match when={message.role === "user"}>
|
||||
<UserMessage
|
||||
index={index()}
|
||||
focused={focusedMessage() === message.id}
|
||||
navigating={navMode()}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
dialog.replace(() => (
|
||||
@@ -1277,6 +1477,7 @@ export function Session() {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
inert={navMode()}
|
||||
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
@@ -1326,6 +1527,8 @@ function UserMessage(props: {
|
||||
parts: Part[]
|
||||
onMouseUp: () => void
|
||||
index: number
|
||||
focused?: boolean
|
||||
navigating?: boolean
|
||||
pending?: string
|
||||
}) {
|
||||
const ctx = use()
|
||||
@@ -1357,7 +1560,7 @@ function UserMessage(props: {
|
||||
<box
|
||||
id={props.message.id}
|
||||
border={["left"]}
|
||||
borderColor={color()}
|
||||
borderColor={props.focused ? color() : props.navigating ? theme.border : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
>
|
||||
@@ -1372,10 +1575,10 @@ function UserMessage(props: {
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
backgroundColor={props.focused || hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text}>{text()}</text>
|
||||
<text fg={props.navigating && !props.focused ? theme.textMuted : theme.text}>{text()}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { useSDK } from "@tui/context/sdk"
|
||||
import type { useSync } from "@tui/context/sync"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||
import { strip } from "@tui/component/prompt/part"
|
||||
|
||||
type SDK = ReturnType<typeof useSDK>
|
||||
type Sync = ReturnType<typeof useSync>
|
||||
type Navigate = ReturnType<typeof useRoute>["navigate"]
|
||||
|
||||
// File parts keep their server identifiers when seeding a new session (fork) but
|
||||
// are stripped when the draft is re-submitted into the same session (revert).
|
||||
export function collectPrompt(sync: Sync, messageID: string, options: { stripFiles: boolean }): PromptInfo | undefined {
|
||||
const parts = sync.data.part[messageID]
|
||||
if (!parts) return
|
||||
return parts.reduce(
|
||||
(agg, part) => {
|
||||
if (part.type === "text") {
|
||||
if (!part.synthetic) agg.input += part.text
|
||||
}
|
||||
if (part.type === "file") agg.parts.push(options.stripFiles ? strip(part) : part)
|
||||
return agg
|
||||
},
|
||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||
)
|
||||
}
|
||||
|
||||
export function collectText(sync: Sync, messageID: string) {
|
||||
const parts = sync.data.part[messageID] ?? []
|
||||
return parts.reduce((text, part) => {
|
||||
if (part.type === "text" && !part.synthetic) text += part.text
|
||||
return text
|
||||
}, "")
|
||||
}
|
||||
|
||||
export function revert(options: {
|
||||
sdk: SDK
|
||||
sync: Sync
|
||||
sessionID: string
|
||||
messageID: string
|
||||
setPrompt?: (prompt: PromptInfo) => void
|
||||
}) {
|
||||
void options.sdk.client.session.revert({ sessionID: options.sessionID, messageID: options.messageID })
|
||||
if (!options.setPrompt) return
|
||||
const prompt = collectPrompt(options.sync, options.messageID, { stripFiles: true })
|
||||
if (prompt) options.setPrompt(prompt)
|
||||
}
|
||||
|
||||
export async function fork(options: {
|
||||
sdk: SDK
|
||||
sync: Sync
|
||||
navigate: Navigate
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}) {
|
||||
const result = await options.sdk.client.session.fork({ sessionID: options.sessionID, messageID: options.messageID })
|
||||
options.navigate({
|
||||
type: "session",
|
||||
sessionID: result.data!.id,
|
||||
prompt: collectPrompt(options.sync, options.messageID, { stripFiles: false }),
|
||||
})
|
||||
}
|
||||
|
||||
export * as MessageActions from "./message-actions"
|
||||
@@ -58,6 +58,98 @@ test("legacy page key aliases compile as page keys", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("message focus bindings resolve and are scoped to the focus mode", async () => {
|
||||
const result: {
|
||||
sequences?: Record<string, string[][]>
|
||||
base?: Record<string, number>
|
||||
messages?: Record<string, number>
|
||||
} = {}
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const config = createTuiResolvedConfig()
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
|
||||
const offBase = keymap.registerLayer({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
commands: [{ name: "session.parent", run() {} }],
|
||||
bindings: config.keybinds.gather("test.base", ["session.parent"]),
|
||||
})
|
||||
const offFocus = keymap.registerLayer({
|
||||
mode: "messages",
|
||||
commands: [
|
||||
{ name: "session.message.focus.previous", run() {} },
|
||||
{ name: "session.message.focus.next", run() {} },
|
||||
{ name: "session.message.focus.revert", run() {} },
|
||||
],
|
||||
bindings: config.keybinds.gather("test.focus", [
|
||||
"session.message.focus.previous",
|
||||
"session.message.focus.next",
|
||||
"session.message.focus.revert",
|
||||
]),
|
||||
})
|
||||
|
||||
const sequence = (command: string) =>
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: [command] })
|
||||
.get(command)
|
||||
?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
|
||||
result.sequences = {
|
||||
previous: sequence("session.message.focus.previous"),
|
||||
next: sequence("session.message.focus.next"),
|
||||
revert: sequence("session.message.focus.revert"),
|
||||
}
|
||||
|
||||
const activeCounts = () =>
|
||||
Object.fromEntries(
|
||||
Array.from(
|
||||
keymap.getCommandBindings({
|
||||
visibility: "active",
|
||||
commands: ["session.parent", "session.message.focus.previous"],
|
||||
}),
|
||||
([command, bindings]) => [command, bindings.length],
|
||||
),
|
||||
)
|
||||
result.base = activeCounts()
|
||||
const popFocus = getOpencodeModeStack(keymap).push("messages")
|
||||
result.messages = activeCounts()
|
||||
popFocus()
|
||||
|
||||
onCleanup(() => {
|
||||
offFocus()
|
||||
offBase()
|
||||
offKeymap()
|
||||
})
|
||||
|
||||
return (
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<box />
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(result.sequences).toEqual({
|
||||
previous: [["up"], ["k"]],
|
||||
next: [["down"], ["j"]],
|
||||
revert: [["r"]],
|
||||
})
|
||||
expect(result.base).toEqual({
|
||||
"session.parent": 1,
|
||||
"session.message.focus.previous": 0,
|
||||
})
|
||||
expect(result.messages).toEqual({
|
||||
"session.parent": 0,
|
||||
// up and k both bind here
|
||||
"session.message.focus.previous": 2,
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const counts: Record<string, Record<string, number>> = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user