From f0aa34d8c4ac5306768e814e313127de06c2b582 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Wed, 7 Oct 2026 21:31:13 -0700 Subject: [PATCH] bench: add reproducible engine and context shaping workload Benchmark query compilation, full core/shape processing, and isolated context shaping with Criterion and bundled PR998 inputs. Document timing boundaries and plugin rebuild requirements. AI-assisted: OpenAI Codex. --- Cargo.lock | 115 +- Cargo.toml | 6 + benches/engine.rs | 185 ++ benches/fixtures/pr998/LICENSE | 21 + benches/fixtures/pr998/after-ask-composer.tsx | 860 ++++++++++ benches/fixtures/pr998/after-ask-panel.tsx | 1015 +++++++++++ benches/fixtures/pr998/after-thread-state.ts | 375 +++++ benches/fixtures/pr998/after-thread.ts | 1493 +++++++++++++++++ .../fixtures/pr998/before-ask-composer.tsx | 858 ++++++++++ benches/fixtures/pr998/before-ask-panel.tsx | 994 +++++++++++ benches/fixtures/pr998/before-thread-state.ts | 359 ++++ benches/fixtures/pr998/before-thread.ts | 1405 ++++++++++++++++ benches/fixtures/pr998/manifest.json | 34 + benches/support/host.rs | 8 + docs/benchmarks.md | 66 + src/plugin/wasm.rs | 1 + 16 files changed, 7794 insertions(+), 1 deletion(-) create mode 100644 benches/engine.rs create mode 100644 benches/fixtures/pr998/LICENSE create mode 100644 benches/fixtures/pr998/after-ask-composer.tsx create mode 100644 benches/fixtures/pr998/after-ask-panel.tsx create mode 100644 benches/fixtures/pr998/after-thread-state.ts create mode 100644 benches/fixtures/pr998/after-thread.ts create mode 100644 benches/fixtures/pr998/before-ask-composer.tsx create mode 100644 benches/fixtures/pr998/before-ask-panel.tsx create mode 100644 benches/fixtures/pr998/before-thread-state.ts create mode 100644 benches/fixtures/pr998/before-thread.ts create mode 100644 benches/fixtures/pr998/manifest.json create mode 100644 benches/support/host.rs create mode 100644 docs/benchmarks.md diff --git a/Cargo.lock b/Cargo.lock index ab411fe7c..5b9244ce6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,12 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.15" @@ -259,6 +265,12 @@ dependencies = [ "winx", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.54" @@ -288,6 +300,33 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.23" @@ -535,6 +574,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -569,6 +639,12 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -659,6 +735,7 @@ dependencies = [ "assert_cmd", "bytes", "clap", + "criterion", "diffr-core", "gix", "glob", @@ -2075,6 +2152,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2402,6 +2490,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2787,6 +2884,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "option-ext" version = "0.2.0" @@ -3635,6 +3738,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.13.2" @@ -4830,7 +4943,7 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli", - "itertools", + "itertools 0.14.0", "log", "object", "pulley-interpreter", diff --git a/Cargo.toml b/Cargo.toml index 635607d10..c6edcabce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ wasmtime-wasi-http = { version = "49.0.2", default-features = false, features = tikv-jemallocator = "0.7" [dev-dependencies] +criterion = { version = "0.7", default-features = false, features = ["cargo_bench_support"] } diffr-core = { path = "crates/diffr-core", features = ["test-support"] } assert_cmd = "2.0.17" predicates = "3.1.3" @@ -93,3 +94,8 @@ assets = [ [ "diffr.1", "usr/share/man/man1/", "644" ], [ "CHANGELOG.md", "usr/share/doc/difftastic/", "644" ], ] + +[[bench]] +name = "engine" +path = "benches/engine.rs" +harness = false diff --git a/benches/engine.rs b/benches/engine.rs new file mode 100644 index 000000000..e74f79467 --- /dev/null +++ b/benches/engine.rs @@ -0,0 +1,185 @@ +//! See docs/benchmarks.md for workloads, timing boundaries and comparisons. +use criterion::{criterion_group, criterion_main, BatchSize, Criterion, Throughput}; +use diffr_core::config::{Config, Params}; +use diffr_core::options::DiffOptions; +use diffr_core::pairing::Pairing; +use diffr_core::protocol::{project, Diff, FileChange, FileRef, FileStatus, Source}; +use diffr_core::summary::{DiffResult, FileFormat}; +use diffr_core::{config, pairing, protocol}; +use std::hint::black_box; +use std::num::NonZeroUsize; +use std::path::Path; +use std::time::Duration; + +// Match the CLI allocator on each platform. +#[cfg(not(any(windows, target_os = "illumos", target_os = "freebsd")))] +#[global_allocator] +static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +#[path = "support/host.rs"] +mod host; + +struct Fixture { + path: &'static str, + before: &'static str, + after: &'static str, +} + +macro_rules! fixture { + ($path:literal, $name:literal) => { + Fixture { + path: $path, + before: include_str!(concat!("fixtures/pr998/before-", $name)), + after: include_str!(concat!("fixtures/pr998/after-", $name)), + } + }; +} + +const FILES: &[Fixture] = &[ + fixture!( + "packages/review/app/src/ask-composer.tsx", + "ask-composer.tsx" + ), + fixture!("packages/review/app/src/ask-panel.tsx", "ask-panel.tsx"), + fixture!("packages/review/src/ask/thread-state.ts", "thread-state.ts"), + fixture!("packages/review/src/ask/thread.ts", "thread.ts"), +]; + +fn project_file(fixture: &Fixture, params: &Params) -> (FileChange, Pairing) { + let result = DiffResult::from_sources_with_options( + fixture.path, + fixture.before, + fixture.after, + params, + &DiffOptions { + syntax: true, + ..DiffOptions::default() + }, + ) + .expect("fixture must diff successfully"); + assert!( + matches!(result.file_format, FileFormat::SupportedLanguage(_)), + "fixture fell back from structural diffing: {:?}", + result.file_format + ); + let reference = FileRef { + path: fixture.path.into(), + oid: String::new(), + mode: "100644".into(), + }; + let file = FileChange { + file: Pairing::Both { + lhs: reference.clone(), + rhs: reference, + }, + status: FileStatus::Modified, + tags: Vec::new(), + }; + let diff = project::diff( + &result, + project::Inputs { + file: &file.file, + sizes: (fixture.before.len() as u64, fixture.after.len() as u64), + }, + ); + let Diff::Text { sides, .. } = diff else { + panic!("fixture must produce a text diff") + }; + (file, sides) +} + +fn pipeline(config: &Config) -> host::Pipeline { + host::Pipeline::from_config( + config, + Path::new(env!("CARGO_MANIFEST_DIR")), + NonZeroUsize::new(1).unwrap(), + ) + .expect("bundled shape plugins must load") +} + +fn benchmarks(c: &mut Criterion) { + let default = Config::default(); + let no_context = Config::from_toml_in( + "[plugins.shape.bundled.context]\nenabled = false\n", + Path::new(""), + ) + .expect("valid benchmark configuration"); + let context_only = Config::from_toml_in( + "[plugins.shape]\norder = [\"bundled.context\"]\n", + Path::new(""), + ) + .expect("valid context-only configuration"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let mut startup = c.benchmark_group("init/queries"); + for (name, config) in [("default", &default), ("no_context", &no_context)] { + startup.bench_function(name, |b| { + b.iter(|| black_box(config.compile().expect("queries compile"))) + }); + } + startup.finish(); + + let bytes = FILES + .iter() + .map(|f| f.before.len() + f.after.len()) + .sum::() as u64; + let mut engine = c.benchmark_group("engine/pr998"); + engine.throughput(Throughput::Bytes(bytes)); + for (name, config) in [("default", &default), ("no_context", &no_context)] { + let params = config.compile().expect("queries compile"); + let pipeline = pipeline(config); + // Fail before measuring if a plugin errors or corrupts source text. + for fixture in FILES { + let (file, sides) = project_file(fixture, ¶ms); + let shaped = runtime + .block_on(pipeline.run(&file, sides)) + .expect("plugins succeed"); + assert_eq!(shaped.lhs().unwrap().text, fixture.before); + assert_eq!(shaped.rhs().unwrap().text, fixture.after); + } + engine.bench_function(name, |b| { + b.iter(|| { + for fixture in FILES { + let (file, sides) = project_file(black_box(fixture), ¶ms); + black_box( + runtime + .block_on(pipeline.run(&file, sides)) + .expect("plugins succeed"), + ); + } + }) + }); + } + engine.finish(); + + // Keep the same default-query trees. Clone outside the timed routine because + // plugins mutate their input; reusing shaped output would benchmark a no-op. + let params = default.compile().expect("queries compile"); + let projected: Vec<_> = FILES.iter().map(|f| project_file(f, ¶ms)).collect(); + let context = pipeline(&context_only); + c.bench_function("shape/pr998/context", |b| { + b.iter_batched( + || projected.clone(), + |files| { + for (file, sides) in files { + black_box( + runtime + .block_on(context.run(&file, sides)) + .expect("context succeeds"), + ); + } + }, + BatchSize::PerIteration, + ) + }); +} + +criterion_group! { + name = benches; + config = Criterion::default().sample_size(10).warm_up_time(Duration::from_secs(1)).measurement_time(Duration::from_secs(5)); + targets = benchmarks +} +criterion_main!(benches); diff --git a/benches/fixtures/pr998/LICENSE b/benches/fixtures/pr998/LICENSE new file mode 100644 index 000000000..5a204b27e --- /dev/null +++ b/benches/fixtures/pr998/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 dev.fast + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/benches/fixtures/pr998/after-ask-composer.tsx b/benches/fixtures/pr998/after-ask-composer.tsx new file mode 100644 index 000000000..051a86e3b --- /dev/null +++ b/benches/fixtures/pr998/after-ask-composer.tsx @@ -0,0 +1,860 @@ +import { + type AskCommand, + type AskQuestion, + type AskUsage, + askImageTypes, +} from "@review/ask/thread-state"; +import { fuzzyRank } from "@review/fuzzy-match"; +import * as stylex from "@stylexjs/stylex"; +import { + type ClipboardEvent, + type DragEvent, + type KeyboardEvent, + type ReactElement, + type ReactNode, + type RefObject, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { askMotion } from "./ask-motion.stylex"; +import { controlStyles } from "./controls-styles"; +import { ArrowUpIcon, CloseIcon, ImageIcon } from "./icons"; +import { fontSize, motion, radius } from "./scale.stylex"; +import { tokens } from "./tokens.stylex"; +import { Button, IconButton } from "./ui/button"; +import { menuStyles } from "./ui/menu"; +import { ProgressRing } from "./ui/progress-ring"; +import { surfaceStyles } from "./ui/surface"; +import { fieldStyles } from "./ui/text-field"; +import { useAnchoredPopover } from "./use-anchored-popover"; +import { useDismissOnOutside } from "./use-dismiss-on-outside"; +import { useTooltip } from "./use-tooltip"; + +type ImageType = (typeof askImageTypes)[number]; + +interface Attached { + id: string; + name: string; + mimeType: ImageType; + /** Base64, as ACP sends it. */ + data: string; +} + +const CONTEXT_SHOWN_FROM = 0.5; + +const IMAGES_MAX = 4; + +const IMAGE_BYTES_MAX = 5 * 1024 * 1024; + +const FIND_DELAY_MS = 80; + +const isImageType = (type: string): type is ImageType => + askImageTypes.some((allowed) => allowed === type); + +/** A file read as base64. */ +function readImage(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + const url = String(reader.result); + + resolve(url.slice(url.indexOf(",") + 1)); + }; + + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +/** What the caret is completing: a slash command at the start of the + * question, or a file after an @. */ +type Completion = + | { kind: "command"; query: string } + | { kind: "file"; query: string; start: number; end: number }; + +function completionAt(draft: string, caret: number): Completion | null { + const command = /^\/(\S*)$/.exec(draft); + + if (command) return { kind: "command", query: command[1]! }; + + const before = draft.slice(0, caret); + const file = /(?:^|\s)@([^\s@]*)$/.exec(before); + + if (!file) return null; + + return { + kind: "file", + query: file[1]!, + start: caret - file[1]!.length - 1, + end: caret, + }; +} + +/** + * The first of the placeholders, longest first, that keeps the empty question + * on one line; the last when none does. The question grows to fit its + * placeholder, so one that wraps would make it two lines tall. + */ +function useFittingPlaceholder( + input: RefObject, + placeholders: readonly string[], + empty: boolean, +): string { + const key = placeholders.join("\n"); + const [fitting, setFitting] = useState(0); + + useLayoutEffect(() => { + const question = input.current; + const row = question?.parentElement; + + if (!question || !row || !empty) return; + const candidates = key.split("\n"); + + const measure = () => { + const line = parseFloat(getComputedStyle(question).lineHeight); + + const fits = candidates.findIndex((candidate) => { + question.placeholder = candidate; + + return question.offsetHeight <= line + 1; + }); + + const chosen = fits === -1 ? candidates.length - 1 : fits; + + question.placeholder = candidates[chosen]!; + setFitting(chosen); + }; + + measure(); + const observer = new ResizeObserver(measure); + + observer.observe(row); + let live = true; + + // The serif may load after the first measure, at a different width. + if ("fonts" in document) + void document.fonts.ready.then(() => live && measure()); + + return () => { + live = false; + observer.disconnect(); + }; + }, [input, key, empty]); + + return placeholders[Math.min(fitting, placeholders.length - 1)]!; +} + +/** The checkout's files matching a query, asked for as the reviewer types. */ +function useFiles( + completion: Completion | null, + findFiles: (query: string, signal: AbortSignal) => Promise, +) { + const [found, setFound] = useState<{ query: string; paths: string[] }>(); + const query = completion?.kind === "file" ? completion.query : null; + + useEffect(() => { + if (query === null) return; + const abort = new AbortController(); + + const timer = setTimeout(() => { + findFiles(query, abort.signal) + .then((paths) => setFound({ query, paths })) + // Without them the @ is just text. + .catch(() => {}); + }, FIND_DELAY_MS); + + return () => { + clearTimeout(timer); + abort.abort(); + }; + }, [query, findFiles]); + + // The last answer stands until the next arrives, so the list does not + // flicker while typing. + return query === null ? [] : (found?.paths ?? []); +} + +/** + * Where a question is written: `/` lists the agent's commands, `@` finds a + * file in the checkout, and an agent that reads images takes pasted, + * dropped or attached ones. + */ +export function AskComposer({ + inputRef, + placeholders, + disabled, + canAsk, + stop, + connecting = false, + status, + commands, + acceptsImages, + usage, + findFiles, + permissions, + settings, + onCyclePermissions, + onAsk, +}: { + inputRef: RefObject; + /** Longest first: the first that fits on one line shows. */ + placeholders: readonly string[]; + disabled: boolean; + /** Whether a question can go now: an agent is chosen and none is busy, + * or one is answering and takes a follow-up. */ + canAsk: boolean; + /** Stops the turn under way; a Stop button replaces Ask while set, unless + * a follow-up is written that can go. */ + stop?: () => void; + /** The agent is still starting: Stop shows it connecting, not a turn to + * stop. */ + connecting?: boolean; + status: ReactNode; + commands: AskCommand[] | undefined; + acceptsImages: boolean; + usage: AskUsage | undefined; + findFiles: (query: string, signal: AbortSignal) => Promise; + /** What the agent may do, starting the row below. */ + permissions?: ReactNode; + /** The agent's model and effort, ending the row below. */ + settings?: ReactNode; + onCyclePermissions?: () => void; + /** Resolves false when the question did not go, to put it back. */ + onAsk: (question: AskQuestion) => Promise; +}): ReactElement { + const [draft, setDraft] = useState(""); + const [caret, setCaret] = useState(0); + const [mentions, setMentions] = useState([]); + const [images, setImages] = useState([]); + const [note, setNote] = useState(null); + const [active, setActive] = useState(0); + // Escape hides the list until the question changes. + const [dismissed, setDismissed] = useState(null); + const picker = useRef(null); + const form = useRef(null); + const listId = useId(); + const attachTooltip = useTooltip("Attach images"); + + const stopTooltip = useTooltip( + connecting ? "Connecting… Click to stop." : "Stop", + ); + + const askTooltip = useTooltip("Ask"); + + const placeholder = useFittingPlaceholder( + inputRef, + placeholders, + draft === "", + ); + + const completion = dismissed === draft ? null : completionAt(draft, caret); + const paths = useFiles(completion, findFiles); + + const options: { key: string; label: string; detail?: string }[] = + completion?.kind === "command" + ? fuzzyRank(completion.query, commands ?? [], (command) => [ + command.name, + ]).map((command) => ({ + key: command.name, + label: `/${command.name}`, + detail: command.hint + ? `${command.description} · ${command.hint}` + : command.description, + })) + : completion?.kind === "file" + ? paths.map((path) => { + const slash = path.lastIndexOf("/"); + + // The name first: a deep path would otherwise show only its + // folders. + return slash === -1 + ? { key: path, label: path } + : { + key: path, + label: path.slice(slash + 1), + detail: path.slice(0, slash), + }; + }) + : []; + + const open = options.length > 0; + const shown = Math.min(active, options.length - 1); + const list = useAnchoredPopover(open, form); + const dismissList = useCallback(() => setDismissed(draft), [draft]); + + useDismissOnOutside(form, open, dismissList, true); + + useEffect(() => setActive(0), [completion?.kind, completion?.query]); + + const place = (text: string, at: number) => { + setDraft(text); + setCaret(at); + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(at, at); + }); + }; + + const pick = (index: number) => { + const option = options[index]; + + if (!option || !completion) return; + + if (completion.kind === "command") { + const text = `${option.label} `; + + place(text, text.length); + + return; + } + + const inserted = `@${option.key} `; + + place( + draft.slice(0, completion.start) + inserted + draft.slice(completion.end), + completion.start + inserted.length, + ); + setMentions((current) => + current.includes(option.key) ? current : [...current, option.key], + ); + }; + + const attach = async (files: File[]) => { + const wanted = files.flatMap((file) => { + const { type } = file; + + return isImageType(type) ? [{ file, mimeType: type }] : []; + }); + + if (!wanted.length) return; + const room = IMAGES_MAX - images.length; + const fitting = wanted.filter(({ file }) => file.size <= IMAGE_BYTES_MAX); + + setNote( + fitting.length < wanted.length + ? "Images over 5 MB were left out." + : wanted.length > room + ? `A question takes up to ${IMAGES_MAX} images.` + : null, + ); + + const read = await Promise.all( + fitting.slice(0, Math.max(room, 0)).map(async ({ file, mimeType }) => ({ + id: crypto.randomUUID(), + name: file.name || "Pasted image", + mimeType, + data: await readImage(file), + })), + ); + + setImages((current) => [...current, ...read].slice(0, IMAGES_MAX)); + }; + + const submit = async () => { + const text = draft.trim(); + + if (!text || !canAsk) return; + + // A file stays mentioned while its @ does. + const mentioned = mentions.filter((path) => draft.includes(`@${path}`)); + + const question: AskQuestion = { text }; + + if (mentioned.length) question.mentions = mentioned; + + if (images.length) + question.images = images.map(({ name, mimeType, data }) => ({ + name, + mimeType, + data, + })); + + // Cleared now, so what is written while it goes stays; a question that + // did not go comes back where nothing newer took its place. + const sent = { draft, mentions, images }; + + setDraft(""); + setCaret(0); + setMentions([]); + setImages([]); + setNote(null); + + if (await onAsk(question)) return; + setDraft((current) => current || sent.draft); + setMentions((current) => [...new Set([...sent.mentions, ...current])]); + setImages((current) => (current.length ? current : sent.images)); + }; + + const keydown = (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing) return; + + if (open) { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const step = event.key === "ArrowDown" ? 1 : -1; + + setActive((shown + step + options.length) % options.length); + + return; + } + + if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") { + event.preventDefault(); + pick(shown); + + return; + } + + if (event.key === "Escape") { + // Escape closes the list, not the panel behind it. + event.preventDefault(); + event.stopPropagation(); + setDismissed(draft); + + return; + } + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void submit(); + } + + if (event.key === "Tab" && event.shiftKey && onCyclePermissions) { + event.preventDefault(); + onCyclePermissions(); + } + }; + + const paste = (event: ClipboardEvent) => { + if (!acceptsImages) return; + + const files = [...event.clipboardData.files].filter((file) => + isImageType(file.type), + ); + + if (!files.length) return; + event.preventDefault(); + void attach(files); + }; + + const dragOver = (event: DragEvent) => { + if (acceptsImages && event.dataTransfer.types.includes("Files")) + event.preventDefault(); + }; + + const drop = (event: DragEvent) => { + if (!acceptsImages) return; + event.preventDefault(); + void attach([...event.dataTransfer.files]); + }; + + const share = usage && usage.size > 0 ? usage.used / usage.size : undefined; + + const usageTooltip = useTooltip( + share === undefined ? "" : `${Math.round(share * 100)}% context used`, + { + detail: usage + ? `${usage.used.toLocaleString()} of ${usage.size.toLocaleString()} tokens${ + usage.cost + ? ` · ${new Intl.NumberFormat(undefined, { + style: "currency", + currency: usage.cost.currency, + maximumFractionDigits: 2, + }).format(usage.cost.amount)}` + : "" + }` + : undefined, + }, + ); + + return ( +
+
{ + event.preventDefault(); + void submit(); + }} + onDragOver={dragOver} + onDrop={drop} + > + {open ? ( +
+ {options.map((option, index) => ( +
event.preventDefault()} + onPointerMove={() => setActive(index)} + onClick={() => pick(index)} + > + + {option.label} + + {option.detail ? ( + + {option.detail} + + ) : null} +
+ ))} +
+ ) : null} + + {images.length ? ( +
    + {images.map((image) => ( + + setImages((current) => + current.filter((other) => other.id !== image.id), + ) + } + /> + ))} +
+ ) : null} + +
+