diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index c5de0fa238..290eff559f 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -124,6 +124,11 @@ export const Definitions = { session_quick_switch_7: keybind("7", "keybind.session_quick_switch_7"), session_quick_switch_8: keybind("8", "keybind.session_quick_switch_8"), session_quick_switch_9: keybind("9", "keybind.session_quick_switch_9"), + session_answer_option_1: keybind("alt+1", "keybind.session_answer_option_1"), + session_answer_option_2: keybind("alt+2", "keybind.session_answer_option_2"), + session_answer_option_3: keybind("alt+3", "keybind.session_answer_option_3"), + session_answer_option_4: keybind("alt+4", "keybind.session_answer_option_4"), + session_answer_option_5: keybind("alt+5", "keybind.session_answer_option_5"), stash_delete: keybind("ctrl+d", "keybind.stash_delete"), model_provider_list: keybind("ctrl+a", "keybind.model_provider_list"), @@ -332,6 +337,11 @@ export const CommandMap = { session_quick_switch_7: "session.quick_switch.7", session_quick_switch_8: "session.quick_switch.8", session_quick_switch_9: "session.quick_switch.9", + session_answer_option_1: "session.answer.1", + session_answer_option_2: "session.answer.2", + session_answer_option_3: "session.answer.3", + session_answer_option_4: "session.answer.4", + session_answer_option_5: "session.answer.5", stash_delete: "stash.delete", model_provider_list: "model.dialog.provider", model_favorite_toggle: "model.dialog.favorite", diff --git a/packages/tui/src/i18n/en.ts b/packages/tui/src/i18n/en.ts index a236d84839..676fa89082 100644 --- a/packages/tui/src/i18n/en.ts +++ b/packages/tui/src/i18n/en.ts @@ -177,6 +177,7 @@ export const dict = { "command.session.half_page_down": "Half page down", "command.session.first_message": "First message", "command.session.last_message": "Last message", + "command.session.answer_option": "Send suggested answer {{number}}", "command.session.last_user_message": "Jump to last user message", "command.session.next_message": "Next message", "command.session.previous_message": "Previous message", @@ -1064,6 +1065,11 @@ export const dict = { "keybind.session_quick_switch_7": "Switch to session in quick slot 7", "keybind.session_quick_switch_8": "Switch to session in quick slot 8", "keybind.session_quick_switch_9": "Switch to session in quick slot 9", + "keybind.session_answer_option_1": "Send suggested answer 1", + "keybind.session_answer_option_2": "Send suggested answer 2", + "keybind.session_answer_option_3": "Send suggested answer 3", + "keybind.session_answer_option_4": "Send suggested answer 4", + "keybind.session_answer_option_5": "Send suggested answer 5", "keybind.stash_delete": "Delete stash entry", "keybind.model_provider_list": "Open provider list from model dialog", "keybind.model_favorite_toggle": "Toggle model favorite status", diff --git a/packages/tui/src/i18n/ko.ts b/packages/tui/src/i18n/ko.ts index 460d4710d1..dda9a4591f 100644 --- a/packages/tui/src/i18n/ko.ts +++ b/packages/tui/src/i18n/ko.ts @@ -168,6 +168,7 @@ export const dict: Record = { "command.session.half_page_down": "반 페이지 아래로", "command.session.first_message": "첫 메시지", "command.session.last_message": "마지막 메시지", + "command.session.answer_option": "{{number}}번 추천 답 보내기", "command.session.last_user_message": "내 마지막 메시지로 가기", "command.session.next_message": "다음 메시지", "command.session.previous_message": "이전 메시지", @@ -1027,6 +1028,11 @@ export const dict: Record = { "keybind.session_quick_switch_7": "7번 자리 세션으로 가기", "keybind.session_quick_switch_8": "8번 자리 세션으로 가기", "keybind.session_quick_switch_9": "9번 자리 세션으로 가기", + "keybind.session_answer_option_1": "1번 추천 답 보내기", + "keybind.session_answer_option_2": "2번 추천 답 보내기", + "keybind.session_answer_option_3": "3번 추천 답 보내기", + "keybind.session_answer_option_4": "4번 추천 답 보내기", + "keybind.session_answer_option_5": "5번 추천 답 보내기", "keybind.stash_delete": "치워 둔 입력 지우기", "keybind.model_provider_list": "모델 창에서 AI 서비스 목록 열기", "keybind.model_favorite_toggle": "모델 즐겨찾기 넣기/빼기", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9dee0870dd..8733918068 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -70,6 +70,7 @@ import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" +import * as AnswerOptions from "../../util/answeroptions" import { sessionEpilogue } from "../../util/presentation" import { setPreLayoutSiblingMargin } from "../../util/layout" import { useTuiConfig } from "../../config" @@ -151,6 +152,11 @@ const sessionGlobalBindingCommands = [ "session.line.down", "session.half.page.up", "session.half.page.down", + "session.answer.1", + "session.answer.2", + "session.answer.3", + "session.answer.4", + "session.answer.5", ] as const const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const @@ -255,6 +261,23 @@ export function Session() { return messages().findLast((x) => x.role === "assistant") }) + // Suggested answers belong to the newest finished reply only, and vanish once the user sends anything. + const answerOptions = createMemo(() => { + const message = messages().at(-1) + if (!message || message.role !== "assistant" || !isFinal(message)) return + const text = (sync.data.part[message.id] ?? []).findLast((part) => part.type === "text") + if (!text || text.type !== "text") return + const options = AnswerOptions.parse(text.text.trim()).options + return options.length ? { messageID: message.id, options } : undefined + }) + + function sendAnswer(option: string) { + if (!prompt || disabled()) return + prompt.set({ input: option, parts: [] }) + prompt.submit() + toBottom() + } + const dimensions = useTerminalDimensions() const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") const [sidebarOpen, setSidebarOpen] = createSignal(false) @@ -1112,6 +1135,18 @@ export function Session() { moveChild(-1) }), }, + ...Array.from({ length: 5 }, (_, i) => ({ + title: language.t("command.session.answer_option", { number: i + 1 }), + value: `session.answer.${i + 1}`, + category: "Session", + hidden: true, + enabled: (answerOptions()?.options.length ?? 0) > i, + run: () => { + if (prompt?.current.input.trim()) return + const option = answerOptions()?.options[i] + if (option) sendAnswer(option) + }, + })), ]) const sessionCommands = createMemo(() => @@ -1319,6 +1354,8 @@ export function Session() { @@ -1501,7 +1538,46 @@ function UserMessage(props: { ) } -function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { +function isFinal(message: AssistantMessage) { + return !!message.time.completed && !!message.finish && !["tool-calls", "unknown"].includes(message.finish) +} + +function AnswerOptionList(props: { options: string[]; onOption: (option: string) => void }) { + const { theme } = useTheme() + const [hover, setHover] = createSignal() + return ( + alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> + + {(option, index) => { + const shortcut = useCommandShortcut(`session.answer.${index() + 1}`) + return ( + setHover(index())} + onMouseOut={() => setHover(undefined)} + onMouseUp={() => props.onOption(option)} + > + + {index() + 1}. + {option} + + {shortcut()} + + + + ) + }} + + + ) +} + +function AssistantMessage(props: { + message: AssistantMessage + parts: Part[] + last: boolean + options: string[] + onOption: (option: string) => void +}) { const language = useLanguage() const ctx = use() const local = useLocal() @@ -1553,6 +1629,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las ) }} + 0}> + + x.type === "tool" && x.tool === "task")}> @@ -1742,14 +1821,22 @@ function ReasoningHeader(props: { function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() const { theme, syntax } = useTheme() + // A finished reply's options line is drawn as choices (or dropped), never as raw text. + const content = createMemo(() => { + const text = props.part.text.trim() + if (!props.message.time.completed) return text + const parts = ctx.sync.data.part[props.message.id] ?? [] + if (parts.findLast((part) => part.type === "text")?.id !== props.part.id) return text + return AnswerOptions.parse(text).body + }) return ( - + alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> = 0 && !lines[last]!.trim()) last-- + if (last < 0) return none + + const line = lines[last]!.trim() + const raw = (line.match(BRACKETED) ?? line.match(BARE))?.[1] + if (raw === undefined) return none + + // An odd number of fences above the line means it sits inside an unclosed code block. + const fences = lines.slice(0, last).filter((item) => FENCE.test(item)).length + if (fences % 2 === 1) return none + + const options = raw + .split("|") + .map((item) => item.trim()) + .filter(Boolean) + if (options.length < 2) return none + + return { body: lines.slice(0, last).join("\n").trimEnd(), options: options.slice(0, MAX) } +} diff --git a/packages/tui/test/util/answeroptions.test.ts b/packages/tui/test/util/answeroptions.test.ts new file mode 100644 index 0000000000..6cfc8f9709 --- /dev/null +++ b/packages/tui/test/util/answeroptions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { parse } from "../../src/util/answeroptions" + +describe("util.answeroptions", () => { + test("reads a bracketed options line and strips it from the body", () => { + expect(parse("Pick one.\n\n[OPTIONS: Merge it now | Show me the diff]\n")).toEqual({ + body: "Pick one.", + options: ["Merge it now", "Show me the diff"], + }) + }) + + test("reads an options line without brackets", () => { + expect(parse("Pick one.\nOPTIONS: a | b | c")).toEqual({ body: "Pick one.", options: ["a", "b", "c"] }) + }) + + test("leaves a single option as plain text", () => { + const text = "Pick one.\n[OPTIONS: only one]" + expect(parse(text)).toEqual({ body: text, options: [] }) + }) + + test("cuts more than five options to five", () => { + expect(parse("x\n[OPTIONS: 1 | 2 | 3 | 4 | 5 | 6 | 7]").options).toEqual(["1", "2", "3", "4", "5"]) + }) + + test("ignores an options line inside an unclosed code fence", () => { + const text = "Example:\n```\n[OPTIONS: a | b]" + expect(parse(text)).toEqual({ body: text, options: [] }) + }) + + test("ignores an options line that is not the last line", () => { + const text = "[OPTIONS: a | b]\nMore text after." + expect(parse(text)).toEqual({ body: text, options: [] }) + }) +})