diff --git a/.gitignore b/.gitignore index 3a44c749f..28cabfba0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -.factory \ No newline at end of file +.factory + diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf3..4eaff4003 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -196,6 +196,11 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate chatInputRef?.current?.insertIfEmpty(content); }, [chatInputRef]); + /** Insert a quoted reply into the input box (user decides how to send it). */ + const handleQuoteReply = useCallback((quote: string) => { + chatInputRef?.current?.prependText(quote); + }, [chatInputRef]); + const { loading, error, messages, entryIds, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, @@ -584,6 +589,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate onNavigate={sessionBusy ? undefined : handleNavigate} prevAssistantEntryId={sessionBusy ? undefined : prevAssistantEntryId} onEditContent={handleEditContent} + onQuoteReply={handleQuoteReply} showTimestamp={showTimestamp} prevTimestamp={idx > 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined} sessionId={session?.id ?? sessionIdRef.current ?? undefined} diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index cc6bd6822..218220f4b 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -1,22 +1,35 @@ "use client"; -import { useMemo, type MouseEvent } from "react"; +import { createContext, useContext, useEffect, useId, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import { resolveLocalFileHref } from "@/lib/file-links"; import { encodeFilePathForApi } from "@/lib/file-paths"; import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown"; import { MermaidBlock, CodeBlock } from "./MermaidBlock"; +import { QuoteReplyPopover } from "./QuoteReplyPopover"; +import { useI18n } from "@/hooks/useI18n"; +import { parseParagraph, type ParsedSegment } from "@/lib/quote-reply"; interface MarkdownBodyProps { children: string; className?: string; isStreaming?: boolean; cwd?: string; - onOpenFile?: (filePath: string) => void; + onOpenFile?: (filePath: string, fileName?: string) => void; + /** When set (assistant messages), each paragraph becomes hoverable/clickable + * to pop a quote-reply popover. */ + onQuoteReply?: (quote: string) => void; } -export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) { +/** Exactly one quote-reply popover can be open at a time (per message body). */ +const QuoteOpenContext = createContext<{ openId: string | null; setOpenId: (id: string | null) => void }>({ + openId: null, + setOpenId: () => {}, +}); + +export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile, onQuoteReply }: MarkdownBodyProps) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]); + const [openId, setOpenId] = useState(null); // Stable renderer identities keep stateful blocks mounted across message hover updates. const components = useMemo(() => ({ code({ className, children, ...props }) { @@ -79,6 +92,26 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile // eslint-disable-next-line @next/next/no-img-element return {alt; }, + p({ children, ...props }) { + delete props.node; + const pid = useId(); + if (!onQuoteReply) return

{children}

; + return ( + + {children} + + ); + }, + li({ children, ...props }) { + delete props.node; + const pid = useId(); + if (!onQuoteReply) return
  • {children}
  • ; + return ( + + {children} + + ); + }, table({ children }) { return (
    @@ -86,17 +119,156 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
    ); }, - }), [cwd, isStreaming, onOpenFile]); + tr({ children, ...props }) { + delete props.node; + const pid = useId(); + if (!onQuoteReply) return {children}; + return ( + + {children} + + ); + }, + }), [cwd, isStreaming, onOpenFile, onQuoteReply]); + + return ( + +
    + + {normalizedMarkdown} + +
    +
    + ); +} +/** A

    /

  • whose plain text is parsed on hover (desktop) / click (mobile) + * to pop a quote-reply popover. The parse result is locked once shown so a + * streaming tail doesn't make the popover flicker; re-engaging re-parses. */ +/** Plain text of a quoteable element, excluding transient UI children (the + * follow-mouse tooltip) so the quoted reply isn't polluted. */ +function getQuoteText(el: HTMLElement | null): string { + if (!el) return ""; + const clone = el.cloneNode(true) as HTMLElement; + clone.querySelectorAll("[data-quote-tip]").forEach((n) => n.remove()); + return clone.textContent ?? ""; +} + +function QuoteableParagraph({ children, onQuoteReply, onOpenFile, cwd, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; onOpenFile?: (filePath: string, fileName?: string) => void; cwd?: string; as?: "p" | "li" | "tr"; pid: string }) { + const { openId, setOpenId } = useContext(QuoteOpenContext); + const { t } = useI18n(); + const open = openId === pid; + const ref = useRef(null); + const [segments, setSegments] = useState(null); + const [showTip, setShowTip] = useState(false); + const tipRef = useRef(null); + // Detect touch capability lazily (same approach as useIsMobile but local). + const [coarse] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches); + + // Another paragraph opened its popover → close ours (only one popover at a time). + useEffect(() => { + if (!open && segments) setSegments(null); + }, [open, segments]); + + // When the popover opens, ensure it's in view (the paragraph near the + // bottom of the viewport would otherwise push it out of sight). + const popoverRef = useRef(null); + useEffect(() => { + if (segments && popoverRef.current) { + popoverRef.current.scrollIntoView({ block: "nearest" }); + } + }, [segments]); + + const openPopover = () => { + if (segments || open) return; + // For table rows, join cell text with " | " so the quoted line reads like + // a markdown row instead of all cells mashed together. + const el = ref.current; + const text = as === "tr" && el + ? Array.from(el.querySelectorAll("td, th")).map((c) => (c.textContent ?? "").trim()).join(" | ") + : getQuoteText(el); + // Any paragraph is quoteable (not just questions): closed questions get + // option buttons, everything else gets a fallback quote button. + const parsed = parseParagraph(text); + if (parsed.length > 0) { + setSegments(parsed); + setOpenId(pid); + } + }; + const closePopover = () => { + setSegments(null); + setOpenId(null); + }; + // Click toggles: show on first click, hide on the second. + const toggle = () => { + if (segments) closePopover(); + else openPopover(); + }; + + const Tag = as as React.ElementType; + // Follow-the-mouse tooltip: position updated imperatively on mousemove (no + // re-render per move); mouseenter sets it via rAF so it shows even if the + // pointer doesn't move afterwards. + const moveTip = (x: number, y: number) => { + if (tipRef.current) { + tipRef.current.style.left = `${x + 12}px`; + tipRef.current.style.top = `${y + 14}px`; + } + }; + const showTooltip = (e: MouseEvent) => { + if (coarse) return; + setShowTip(true); + const { clientX, clientY } = e; + requestAnimationFrame(() => moveTip(clientX, clientY)); + }; + const hideTooltip = () => { + setShowTip(false); + }; return ( -
    - - {normalizedMarkdown} - -
    + ) => moveTip(e.clientX, e.clientY)} + onMouseLeave={hideTooltip} + onClick={toggle} + style={{ position: "relative", cursor: "pointer" }} + > + {children} + {showTip && !segments && ( + + {t("chat.quoteReplyHint")} + + )} + {segments && ( + { onQuoteReply(q); closePopover(); }} + onOpenFile={onOpenFile} + cwd={cwd} + /> + )} + ); } diff --git a/components/MessageView.tsx b/components/MessageView.tsx index d73bee37f..6b6018f6f 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -54,6 +54,7 @@ function loadThinkingContent(sessionId: string, entryId: string, blockIndex: num } interface Props { + onQuoteReply?: (quote: string) => void; message: AgentMessage; isStreaming?: boolean; toolResults?: Map; @@ -98,12 +99,12 @@ function haveSameRelevantToolResults( return true; } -export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { +export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onQuoteReply, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { if (message.role === "user") { return ; } if (message.role === "assistant") { - return ; + return ; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired @@ -345,6 +346,7 @@ function AssistantMessageView({ modelNames, cwd, onOpenFile, + onQuoteReply, showTimestamp, prevTimestamp, sessionId, @@ -356,6 +358,7 @@ function AssistantMessageView({ modelNames?: Record; cwd?: string; onOpenFile?: (filePath: string) => void; + onQuoteReply?: (quote: string) => void; showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; @@ -529,7 +532,7 @@ function AssistantMessageView({
    {blockItems.map(({ block, originalIndex }) => ( - + ))}
    @@ -603,9 +606,9 @@ function AssistantMessageView({ ); } -function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) { +function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, onQuoteReply, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map; cwd?: string; onOpenFile?: (filePath: string) => void; onQuoteReply?: (quote: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) { if (block.type === "text") { - return ; + return ; } if (block.type === "thinking") { return ; @@ -619,8 +622,8 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal return null; } -function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) { - return {block.text}; +function TextBlock({ block, isStreaming, cwd, onOpenFile, onQuoteReply }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; onQuoteReply?: (quote: string) => void }) { + return {block.text}; } function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { diff --git a/components/QuoteReplyPopover.tsx b/components/QuoteReplyPopover.tsx new file mode 100644 index 000000000..bf3d5734d --- /dev/null +++ b/components/QuoteReplyPopover.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import type { Ref } from "react"; +import { encodeFilePathForApi, joinFilePath } from "@/lib/file-paths"; +import type { ParsedSegment, QuoteOption } from "@/lib/quote-reply"; +import { extractFilePaths, formatQuote } from "@/lib/quote-reply"; + +interface Props { + segments: ParsedSegment[]; + /** Called with the formatted quote-reply text (caller inserts it into the input). */ + onPick: (quote: string) => void; + /** Open a file path (from inline paths mentioned in the text). */ + onOpenFile?: (filePath: string, fileName?: string) => void; + /** Session cwd used to resolve relative paths before checking /api/files. */ + cwd?: string; + /** Optional ref to the popover element (caller scrolls it into view on open). */ + innerRef?: Ref; +} + +/** + * A button row for one paragraph's parsed questions. Each question gets its + * own sub-row: detected options (是/否, A/B, …) when available, otherwise a + * single fallback "quote" button. Clicking inserts a quoted reply into the + * input box — never sends. + */ +export function QuoteReplyPopover({ segments, onPick, onOpenFile, cwd, innerRef }: Props) { + const { t } = useI18n(); + // Show every segment: closed questions get option buttons, the rest get a + // fallback quote button. (Any paragraph is quoteable.) + const questions = segments; + if (questions.length === 0) return null; + + // Inline file paths mentioned in the text (assistant often lists files as + // plain text, not links). Verify each against the backend before offering + // an "open" action so we don't render dead buttons. + const [existingFiles, setExistingFiles] = useState([]); + useEffect(() => { + let cancelled = false; + const allPaths = Array.from(new Set(questions.flatMap((seg) => extractFilePaths(seg.text)))); + const absPaths = allPaths.map((p) => + p.startsWith("/") ? p : (cwd ? joinFilePath(cwd, p) : p), + ); + Promise.all( + absPaths.map(async (abs) => { + try { + const res = await fetch(`/api/files/${encodeFilePathForApi(abs)}?type=meta`); + return res.ok ? abs : null; + } catch { + return null; + } + }), + ).then((found) => { + if (!cancelled) setExistingFiles(found.filter((f): f is string => !!f)); + }); + return () => { cancelled = true; }; + }, [questions, cwd]); + + return ( + e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > + {onOpenFile && existingFiles.length > 0 && ( + + {existingFiles.map((abs) => ( + + ))} + + )} + {questions.map((seg, i) => ( + + ))} + + ); +} + +function SegmentRow({ + segment, + onPick, + t, +}: { + segment: ParsedSegment; + onPick: (quote: string) => void; + t: (k: string) => string; +}) { + const options: QuoteOption[] = + segment.options ?? [{ label: t("chat.quoteReply"), value: "" }]; + return ( + + {options.map((opt, j) => ( + + ))} + + ); +} + +function FolderOpenIcon() { + return ( + + ); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..057379d56 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -217,6 +217,9 @@ export const enLocale: LocalePlugin = { "chat.compactContext": "Compact context", "chat.compacting": "Compacting…", "chat.compact": "Compact", + "chat.quoteReply": "Quote reply", + "chat.quoteReplyHint": "Click to quote reply", + "i18n.openFile": "Open", "chat.stopAgent": "Stop agent", "chat.stop": "Stop", "chat.disableSound": "Disable completion sound", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..5b92d5a17 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -217,6 +217,9 @@ export const zhCNLocale: LocalePlugin = { "chat.compactContext": "压缩上下文", "chat.compacting": "正在压缩…", "chat.compact": "压缩", + "chat.quoteReply": "引用回复", + "chat.quoteReplyHint": "点击引用回复", + "i18n.openFile": "打开", "chat.stopAgent": "停止 Agent", "chat.stop": "停止", "chat.disableSound": "关闭完成提示音", diff --git a/lib/quote-reply.test.mjs b/lib/quote-reply.test.mjs new file mode 100644 index 000000000..795b26a31 --- /dev/null +++ b/lib/quote-reply.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { detectOptions, formatQuote, parseParagraph, splitQuestions } from "./quote-reply.ts"; + +test("quoted replies preserve underscores in identifiers and env var names", () => { + // Regression: clean() used to strip `_` as if it were markdown emphasis, + // mangling PI_WEB_BASE_DOMAIN → PIWEBBASEDOMAIN in quoted replies. + assert.equal(formatQuote("命名你定:PI_WEB_BASE_DOMAIN"), "> 命名你定:PI_WEB_BASE_DOMAIN\n"); + const [seg] = splitQuestions("用 PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.match(seg, /PI_WEB_BASE_DOMAIN/); + assert.match(seg, /PIWEBPROXYSUBFIX/); +}); + +test("splitQuestions keeps an A-还是-B choice whole with underscores intact", () => { + const parts = splitQuestions("命名你定:PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.deepEqual(parts, ["命名你定:PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"]); +}); + +test("formatQuote keeps the rendered text verbatim (no markdown processing)", () => { + // The input is already-rendered text — literal chars survive quoting. + assert.equal(formatQuote("用 *斜体* 强调 `code` ~~删除~~"), "> 用 *斜体* 强调 `code` ~~删除~~\n"); +}); + +test("detectOptions still recognizes A-还是-B choices and keeps underscores", () => { + const options = detectOptions("命名你定:PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.ok(options, "expected a choice to be detected"); + assert.equal(options.length, 2); + assert.ok(options.some((o) => o.value.includes("PI_WEB_BASE_DOMAIN"))); + assert.ok(options.some((o) => o.value.includes("PIWEBPROXYSUBFIX"))); +}); + +test("parseParagraph yields options + full quoted segment for env-var choices", () => { + const [seg] = parseParagraph("用 PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.equal(seg.text, "用 PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.ok(seg.options?.length === 2); +}); diff --git a/lib/quote-reply.ts b/lib/quote-reply.ts new file mode 100644 index 000000000..2df904476 --- /dev/null +++ b/lib/quote-reply.ts @@ -0,0 +1,160 @@ +/** + * Quote-reply helpers: detect questions/options in an assistant message and + * format a quoted reply (markdown blockquote + optional pre-filled answer) + * for insertion into the input box. The user then decides how to send it + * (prompt / steer / followUp) — these helpers never send. + * + * Input is the RENDERED paragraph text (DOM textContent): markdown syntax has + * already been consumed by the renderer, so the text is used verbatim — no + * character stripping. Literal chars (underscores in PI_WEB_BASE_DOMAIN, `*` + * inside code, …) must survive quoting intact. + */ + +export interface QuoteOption { + /** Short label for the button. */ + label: string; + /** Pre-filled answer line under the quote. */ + value: string; +} + +/** Truncate to `n` chars with an ellipsis. */ +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n).trimEnd() + "…" : s; +} + +/** Trim filler words from an option fragment pulled out of a sentence. */ +function cleanOption(s: string): string { + return s + .replace(/^(我要|我想我?|你想要?|你想我?|你想要?|想要?|需要|你来|你来?|用|使用|选|选择|我|你)\s*/u, "") + .replace(/[吗吧呢]?$/u, "") + .replace(/[??,,。.!!、]$/gu, "") + .trim(); +} + +/** + * Split a paragraph into question/segment chunks by terminal punctuation and + * choice connectors. Returns [] for non-question paragraphs (caller decides + * whether to still offer a plain quote). + */ +export function splitQuestions(text: string): string[] { + const t = text.trim(); + if (!t) return []; + // Split AFTER ?/? and on ;; — but NOT before 还是/或者, so that an + // "A 还是 B" choice stays whole for detectOptions to match as one segment. + const parts = t + .split(/(?<=[??])|[;;]/u) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return parts; +} + +/** Whether a segment looks like a question at all (used to decide engagement). */ +export function isQuestion(seg: string): boolean { + return /[??]\s*$|吗[??]?\s*$|是否|要不要|能不能|可不可以|还是|或者/u.test(seg); +} + +/** + * Detect concrete options in a question segment. + * Returns null when no clear options can be extracted (caller falls back to a + * plain quote with an empty answer line). + */ +export function detectOptions(seg: string): QuoteOption[] | null { + const t = seg.trim(); + if (!t) return null; + + // 1) Explicit choice: "A 还是 B" / "A 或者 B" / "A or B" (\bor\b so + // "store"/"word" don't split at their internal "or"). + const choice = t.match(/^(.+?)\s*(?:还是|或者|或者还是|\bor\b)\s*(.+?)[??]?\s*$/iu); + if (choice) { + const a = cleanOption(choice[1]); + const b = cleanOption(choice[2]); + if (a && b && a !== b) { + return [ + { label: truncate(a, 12), value: a }, + { label: truncate(b, 12), value: b }, + ]; + } + } + + // 2) Yes/no question (Chinese cues + trailing ?). But NOT open-ended + // questions (怎么做/哪个/什么/…) — those have no yes/no answer, so fall + // through to the plain-quote fallback. + const openEnded = /怎么|如何|怎样|哪个|哪些|什么|为什么|为何|谁|多少|几(个|点|时)?|what|how|why|who|where/u.test(t); + const yesNo = !openEnded && (/[??]\s*$/u.test(t) || /(?:吗|吧|呢)[??]?\s*$/u.test(t) || /是否|要不要|能不能|可不可以|要不要我|需要我/u.test(t)); + if (yesNo) { + // Try to surface the action ("要我X吗" → "好,X") for a more concrete button. + const action = t + .replace(/^(要我|需要我|要不要我?|是否|能不能|可不可以|要不要|或者|还是|你能|你可以|请|麻烦)\s*/u, "") + .replace(/[吗吧呢啊呀]?[??]+$/u, "") // trailing 吗?/? + .replace(/[吗吧呢啊呀]$/u, "") // bare trailing 吗/吧/呢 + .trim(); + if (action && action.length <= 16) { + return [ + { label: `好,${truncate(action, 10)}`, value: "是的" }, + { label: "不用", value: "不用了" }, + ]; + } + return [ + { label: "是", value: "是的" }, + { label: "否", value: "不用了" }, + ]; + } + + // 3) Not a recognizable closed question. + return null; +} + +/** + * Format a quoted reply. Each source line is prefixed with "> "; an optional + * pre-filled answer line follows (empty line if none) so the user types under + * the quote, email-reply style. + */ +export function formatQuote(seg: string, value?: string): string { + const quote = seg + .split("\n") + .map((l) => `> ${l}`) + .join("\n"); + return value ? `${quote}\n${value}` : `${quote}\n`; +} + +/** + * Extract candidate file paths from plain text (e.g. "design/foo.md" written + * inline by the assistant, not as a markdown link). Returns de-duplicated + * paths without trailing punctuation. The caller should verify existence via + * the backend before offering them as "open" actions. + */ +export function extractFilePaths(text: string): string[] { + // 1) Paths containing a slash with an extension: dir/name.ext, ./dir/name.ext + // 2) Bare filenames with a common source/doc extension + const re = /(?:\.[\w-]+\/|[\w-]+\/)+[\w.@-]+\.\w+|\b[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|mdx|json|py|go|rs|css|scss|html|sh|yml|yaml|toml|lock|txt|sql|env)\b/gu; + const seen = new Set(); + const out: string[] = []; + for (const m of text.matchAll(re)) { + let p = m[0]; + // Strip trailing punctuation that the regex might have swallowed. + p = p.replace(/[),。、;:!!??)>"'`]$/u, ""); + if (p.length >= 3 && !seen.has(p)) { + seen.add(p); + out.push(p); + } + } + return out; +} + +export interface ParsedSegment { + /** The rendered segment text (verbatim, no stripping). */ + text: string; + /** Detected options, or null when unclear (fallback to plain quote). */ + options: QuoteOption[] | null; +} + +/** + * Parse a whole paragraph into per-segment results (used by the popover to + * list each question with its own button row). + */ +export function parseParagraph(text: string): ParsedSegment[] { + return splitQuestions(text).map((seg) => ({ + text: seg, + options: detectOptions(seg), + })); +}