Compare commits
2 commits
dev
...
nxl/tui-me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
864fc42e0a | ||
|
|
b1491690cd |
6 changed files with 400 additions and 63 deletions
|
|
@ -66,6 +66,7 @@ export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
inert?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
ref?: (ref: PromptRef | undefined) => void
|
ref?: (ref: PromptRef | undefined) => void
|
||||||
hint?: JSX.Element
|
hint?: JSX.Element
|
||||||
|
|
@ -700,7 +701,7 @@ export function Prompt(props: PromptProps) {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input || input.isDestroyed) return
|
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()
|
if (input.focused) input.blur()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1388,7 +1389,7 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = createMemo(() => {
|
const highlight = createMemo(() => {
|
||||||
if (leader()) return theme.border
|
if (leader() || props.inert) return theme.border
|
||||||
if (store.mode === "shell") return theme.primary
|
if (store.mode === "shell") return theme.primary
|
||||||
const agent = local.agent.current()
|
const agent = local.agent.current()
|
||||||
if (!agent) return theme.border
|
if (!agent) return theme.border
|
||||||
|
|
@ -1494,8 +1495,8 @@ export function Prompt(props: PromptProps) {
|
||||||
width="100%"
|
width="100%"
|
||||||
placeholder={placeholderText()}
|
placeholder={placeholderText()}
|
||||||
placeholderColor={theme.textMuted}
|
placeholderColor={theme.textMuted}
|
||||||
textColor={leader() ? theme.textMuted : theme.text}
|
textColor={leader() || props.inert ? theme.textMuted : theme.text}
|
||||||
focusedTextColor={leader() ? theme.textMuted : theme.text}
|
focusedTextColor={leader() || props.inert ? theme.textMuted : theme.text}
|
||||||
minHeight={1}
|
minHeight={1}
|
||||||
maxHeight={maxHeight()}
|
maxHeight={maxHeight()}
|
||||||
onContentChange={() => {
|
onContentChange={() => {
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,14 @@ export const Definitions = {
|
||||||
messages_undo: keybind("<leader>u", "Undo message"),
|
messages_undo: keybind("<leader>u", "Undo message"),
|
||||||
messages_redo: keybind("<leader>r", "Redo message"),
|
messages_redo: keybind("<leader>r", "Redo message"),
|
||||||
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
|
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"),
|
tool_details: keybind("none", "Toggle tool details visibility"),
|
||||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||||
|
|
||||||
|
|
@ -337,6 +345,14 @@ export const CommandMap = {
|
||||||
messages_undo: "session.undo",
|
messages_undo: "session.undo",
|
||||||
messages_redo: "session.redo",
|
messages_redo: "session.redo",
|
||||||
messages_toggle_conceal: "session.toggle.conceal",
|
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",
|
tool_details: "session.toggle.actions",
|
||||||
display_thinking: "session.toggle.thinking",
|
display_thinking: "session.toggle.thinking",
|
||||||
prompt_submit: "prompt.submit",
|
prompt_submit: "prompt.submit",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import * as Clipboard from "@tui/util/clipboard"
|
import * as Clipboard from "@tui/util/clipboard"
|
||||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||||
import { strip } from "@tui/component/prompt/part"
|
import { MessageActions } from "./message-actions"
|
||||||
|
|
||||||
export function DialogMessage(props: {
|
export function DialogMessage(props: {
|
||||||
messageID: string
|
messageID: string
|
||||||
|
|
@ -26,29 +26,14 @@ export function DialogMessage(props: {
|
||||||
value: "session.revert",
|
value: "session.revert",
|
||||||
description: "undo messages and file changes",
|
description: "undo messages and file changes",
|
||||||
onSelect: (dialog) => {
|
onSelect: (dialog) => {
|
||||||
const msg = message()
|
if (!message()) return
|
||||||
if (!msg) return
|
MessageActions.revert({
|
||||||
|
sdk,
|
||||||
void sdk.client.session.revert({
|
sync,
|
||||||
sessionID: props.sessionID,
|
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()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -57,18 +42,8 @@ export function DialogMessage(props: {
|
||||||
value: "message.copy",
|
value: "message.copy",
|
||||||
description: "message text to clipboard",
|
description: "message text to clipboard",
|
||||||
onSelect: async (dialog) => {
|
onSelect: async (dialog) => {
|
||||||
const msg = message()
|
if (!message()) return
|
||||||
if (!msg) return
|
await Clipboard.copy(MessageActions.collectText(sync, props.messageID))
|
||||||
|
|
||||||
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)
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -77,28 +52,14 @@ export function DialogMessage(props: {
|
||||||
value: "session.fork",
|
value: "session.fork",
|
||||||
description: "create a new session",
|
description: "create a new session",
|
||||||
onSelect: async (dialog) => {
|
onSelect: async (dialog) => {
|
||||||
const result = await sdk.client.session.fork({
|
if (!message()) return
|
||||||
|
await MessageActions.fork({
|
||||||
|
sdk,
|
||||||
|
sync,
|
||||||
|
navigate: route.navigate,
|
||||||
sessionID: props.sessionID,
|
sessionID: props.sessionID,
|
||||||
messageID: props.messageID,
|
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()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
For,
|
For,
|
||||||
Match,
|
Match,
|
||||||
on,
|
on,
|
||||||
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
Show,
|
Show,
|
||||||
Switch,
|
Switch,
|
||||||
|
|
@ -57,6 +58,7 @@ import { useDialog } from "../../ui/dialog"
|
||||||
import { DialogAlert } from "../../ui/dialog-alert"
|
import { DialogAlert } from "../../ui/dialog-alert"
|
||||||
import { TodoItem } from "../../component/todo-item"
|
import { TodoItem } from "../../component/todo-item"
|
||||||
import { DialogMessage } from "./dialog-message"
|
import { DialogMessage } from "./dialog-message"
|
||||||
|
import { MessageActions } from "./message-actions"
|
||||||
import type { PromptInfo } from "../../component/prompt/history"
|
import type { PromptInfo } from "../../component/prompt/history"
|
||||||
import { DialogConfirm } from "@tui/ui/dialog-confirm"
|
import { DialogConfirm } from "@tui/ui/dialog-confirm"
|
||||||
import { DialogTimeline } from "./dialog-timeline"
|
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 { DialogRetryAction } from "../../component/dialog-retry-action"
|
||||||
import { SessionRetry } from "@/session/retry"
|
import { SessionRetry } from "@/session/retry"
|
||||||
import { getRevertDiffFiles } from "../../util/revert-diff"
|
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"
|
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
@ -118,6 +120,8 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOCUS_MODE = "messages"
|
||||||
|
|
||||||
const sessionBindingCommands = [
|
const sessionBindingCommands = [
|
||||||
"session.share",
|
"session.share",
|
||||||
"session.rename",
|
"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
|
// snap to bottom when session changes
|
||||||
createEffect(on(() => route.sessionID, toBottom))
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => route.sessionID,
|
||||||
|
() => {
|
||||||
|
cancelScrollAnimation()
|
||||||
|
setNavMode(false)
|
||||||
|
setFocusedMessage(undefined)
|
||||||
|
toBottom()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PathFormatterProvider path={session()?.directory}>
|
<PathFormatterProvider path={session()?.directory}>
|
||||||
|
|
@ -1148,7 +1346,7 @@ export function Session() {
|
||||||
foregroundColor: theme.border,
|
foregroundColor: theme.border,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
stickyScroll={true}
|
stickyScroll={!navMode()}
|
||||||
stickyStart="bottom"
|
stickyStart="bottom"
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
|
|
@ -1223,6 +1421,8 @@ export function Session() {
|
||||||
<Match when={message.role === "user"}>
|
<Match when={message.role === "user"}>
|
||||||
<UserMessage
|
<UserMessage
|
||||||
index={index()}
|
index={index()}
|
||||||
|
focused={focusedMessage() === message.id}
|
||||||
|
navigating={navMode()}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
|
|
@ -1277,6 +1477,7 @@ export function Session() {
|
||||||
toBottom()
|
toBottom()
|
||||||
}}
|
}}
|
||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
|
inert={navMode()}
|
||||||
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
|
||||||
/>
|
/>
|
||||||
</TuiPluginRuntime.Slot>
|
</TuiPluginRuntime.Slot>
|
||||||
|
|
@ -1326,6 +1527,8 @@ function UserMessage(props: {
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
onMouseUp: () => void
|
onMouseUp: () => void
|
||||||
index: number
|
index: number
|
||||||
|
focused?: boolean
|
||||||
|
navigating?: boolean
|
||||||
pending?: string
|
pending?: string
|
||||||
}) {
|
}) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
|
|
@ -1357,7 +1560,7 @@ function UserMessage(props: {
|
||||||
<box
|
<box
|
||||||
id={props.message.id}
|
id={props.message.id}
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={color()}
|
borderColor={props.focused ? color() : props.navigating ? theme.border : color()}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
marginTop={props.index === 0 ? 0 : 1}
|
marginTop={props.index === 0 ? 0 : 1}
|
||||||
>
|
>
|
||||||
|
|
@ -1372,10 +1575,10 @@ function UserMessage(props: {
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
|
backgroundColor={props.focused || hover() ? theme.backgroundElement : theme.backgroundPanel}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
<text fg={theme.text}>{text()}</text>
|
<text fg={props.navigating && !props.focused ? theme.textMuted : theme.text}>{text()}</text>
|
||||||
<Show when={files().length}>
|
<Show when={files().length}>
|
||||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||||
<For each={files()}>
|
<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 () => {
|
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||||
const counts: Record<string, Record<string, number>> = {}
|
const counts: Record<string, Record<string, number>> = {}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue