Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "keybind.session_quick_switch_7"),
session_quick_switch_8: keybind("<leader>8", "keybind.session_quick_switch_8"),
session_quick_switch_9: keybind("<leader>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"),
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/tui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/tui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const dict: Record<TuiI18nKey, string> = {
"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": "이전 메시지",
Expand Down Expand Up @@ -1027,6 +1028,11 @@ export const dict: Record<TuiI18nKey, string> = {
"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": "모델 즐겨찾기 넣기/빼기",
Expand Down
93 changes: 90 additions & 3 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(() =>
Expand Down Expand Up @@ -1319,6 +1354,8 @@ export function Session() {
<Match when={message.role === "assistant"}>
<AssistantMessage
last={lastAssistant()?.id === message.id}
options={answerOptions()?.messageID === message.id ? answerOptions()!.options : []}
onOption={sendAnswer}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
Expand Down Expand Up @@ -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<number>()
return (
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}>
<For each={props.options}>
{(option, index) => {
const shortcut = useCommandShortcut(`session.answer.${index() + 1}`)
return (
<box
onMouseOver={() => setHover(index())}
onMouseOut={() => setHover(undefined)}
onMouseUp={() => props.onOption(option)}
>
<text fg={hover() === index() ? theme.accent : theme.text}>
<span style={{ fg: theme.accent }}>{index() + 1}. </span>
{option}
<Show when={shortcut()}>
<span style={{ fg: theme.textMuted }}> {shortcut()}</span>
</Show>
</text>
</box>
)
}}
</For>
</box>
)
}

function AssistantMessage(props: {
message: AssistantMessage
parts: Part[]
last: boolean
options: string[]
onOption: (option: string) => void
}) {
const language = useLanguage()
const ctx = use()
const local = useLocal()
Expand Down Expand Up @@ -1553,6 +1629,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
)
}}
</For>
<Show when={!isSummary() && props.options.length > 0}>
<AnswerOptionList options={props.options} onOption={props.onOption} />
</Show>
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
<box paddingTop={1} paddingLeft={3}>
<text fg={theme.text}>
Expand Down Expand Up @@ -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 (
<Show when={props.part.text.trim()}>
<Show when={content()}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}>
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
content={content()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
Expand Down
32 changes: 32 additions & 0 deletions packages/tui/src/util/answeroptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// The engine lets a model end its reply with `[OPTIONS: a | b | c]` (see ANSWER_OPTIONS_PROMPT in
// packages/redrob/src/session/prompt.ts). Models sometimes drop the brackets, so both forms count.
const BRACKETED = /^\[OPTIONS:(.*)\]$/
const BARE = /^OPTIONS:(.*)$/
const FENCE = /^\s*```/
const MAX = 5

export type Parsed = { body: string; options: string[] }

export function parse(text: string): Parsed {
const none = { body: text, options: [] }
const lines = text.split("\n")
let last = lines.length - 1
while (last >= 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) }
}
34 changes: 34 additions & 0 deletions packages/tui/test/util/answeroptions.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] })
})
})
Loading