You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/24 18:06:14 UTC
(maka) 01/17: feat(desktop): draw the transcript, markdown and chrome ourselves
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch feat/desktop-gpui-spike
in repository https://gitbox.apache.org/repos/asf/maka.git
commit f9024e0caf22d31b2110b62f2c3cd51dec90f561
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Sep 25 01:35:05 2026 +0800
feat(desktop): draw the transcript, markdown and chrome ourselves
Replace the gpui-kit component composition (MessageScroller, TextView,
Bubble, Clipboard) with our own transcript, streaming Markdown, sidebar,
composer card, permission card, scrollbar and motion clock, following
Waku's approach. Waku is GPL: only its behavior was studied, from written
specs; no code was copied or translated.
Each gpui-kit gap was fixable on its own (fade redraw rate, marker
mending, pinning the sent prompt, pausing the follow), but every fix
fought the component's ownership of the list and the parse. Owning both
is less code, and lets the pieces share one clock, one selection and one
list. gpui-kit stays as the GPUI source, the Root and the composer's
Textarea, whose IME, undo and key handling are tested there.
- Markdown: pulldown-cmark to flat blocks, the last two blocks reparsed
while streaming, the tail mended in a display copy only, a paint-only
fade driven by when source bytes arrived.
- Transcript: one top-aligned list with stable row keys, spliced by
prefix/suffix diff; the sent prompt pinned with end space until the
reply fills the viewport; settled work folded into one summary line.
- Frame budget: 120 ms stream batching, one ~30 Hz animation clock that
parks when idle, cached sidebar and chat views.
- Icons take a color: a GPUI svg does not inherit its parent's text
color, so uncolored icons never painted.
- Accessibility baseline: sidebar/main landmarks and a button role on
every pressable. GPUI drops a cached view's accessibility nodes when it
reuses the view's last frame, so views are drawn uncached while
assistive technology is listening.
The gpui-kit behavior tests are removed with the components they
checked. Visual acceptance (light/dark, fade, pinning, folds, approval,
selection, scrollbar, idle CPU) is still pending.
Generated-by: Claude Code
---
Cargo.lock | 66 +--
crates/desktop/COMPONENTS.md | 163 ++----
crates/desktop/Cargo.toml | 6 +-
crates/desktop/src/chat/composer.rs | 159 ++++++
crates/desktop/src/{chat.rs => chat/mod.rs} | 729 +++++++++++-------------
crates/desktop/src/chat/permission.rs | 208 +++++++
crates/desktop/src/chat/rows.rs | 448 +++++++++++++++
crates/desktop/src/chat/tools.rs | 241 ++++++++
crates/desktop/src/chat/view.rs | 834 ++++++++++++++++++++++++++++
crates/desktop/src/main.rs | 68 ++-
crates/desktop/src/md/fade.rs | 175 ++++++
crates/desktop/src/md/mend.rs | 249 +++++++++
crates/desktop/src/md/mod.rs | 23 +
crates/desktop/src/md/parse.rs | 573 +++++++++++++++++++
crates/desktop/src/md/view.rs | 610 ++++++++++++++++++++
crates/desktop/src/sidebar.rs | 402 ++++++++++++++
crates/desktop/src/theme.rs | 153 +++++
crates/desktop/src/ui/mod.rs | 287 ++++++++++
crates/desktop/src/ui/motion.rs | 95 ++++
crates/desktop/src/ui/scrollbar.rs | 230 ++++++++
crates/desktop/src/ui/select.rs | 310 +++++++++++
crates/desktop/src/workspace.rs | 261 +++++----
crates/desktop/tests/gpui_kit.rs | 137 -----
23 files changed, 5602 insertions(+), 825 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0fcd36c3f6..b29a8e2ee2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1609,15 +1609,6 @@ dependencies = [
"unicode-segmentation",
]
-[[package]]
-name = "convert_case"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
-dependencies = [
- "unicode-segmentation",
-]
-
[[package]]
name = "cooked-waker"
version = "5.0.0"
@@ -2743,7 +2734,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
- "convert_case 0.10.0",
+ "convert_case",
"proc-macro2",
"quote",
"rustc_version",
@@ -5050,7 +5041,6 @@ dependencies = [
"anyhow",
"async-channel",
"async-task",
- "backtrace",
"bindgen 0.72.1",
"bitflags 2.13.2",
"chrono",
@@ -5086,7 +5076,6 @@ dependencies = [
"pollster 0.4.0",
"postage",
"profiling",
- "proptest",
"rand 0.9.5",
"raw-window-handle",
"regex",
@@ -6265,7 +6254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
- "quick-error 2.0.1",
+ "quick-error",
]
[[package]]
@@ -7251,12 +7240,15 @@ dependencies = [
name = "maka-desktop"
version = "0.2.0"
dependencies = [
+ "chrono",
"gpui-kit",
"maka-client",
"maka-event-log",
"maka-protocol",
+ "pulldown-cmark",
"serde_json",
"tokio",
+ "unicode-segmentation",
"uuid",
]
@@ -9589,32 +9581,15 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
"bitflags 2.13.2",
"num-traits",
- "proptest-macro",
"rand 0.9.5",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
- "rusty-fork",
- "tempfile",
"unarray",
]
-[[package]]
-name = "proptest-macro"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "efaa288b896cb2b345da7b7f2110ab19e51565b83495b56fcec98a62f8b1f33e"
-dependencies = [
- "convert_case 0.11.0",
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
[[package]]
name = "prost"
version = "0.14.4"
@@ -9687,12 +9662,6 @@ dependencies = [
"bytemuck",
]
-[[package]]
-name = "quick-error"
-version = "1.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
-
[[package]]
name = "quick-error"
version = "2.0.1"
@@ -10009,7 +9978,7 @@ dependencies = [
"avif-serialize",
"imgref",
"loop9",
- "quick-error 2.0.1",
+ "quick-error",
"rav1e",
"rayon",
"rgb",
@@ -10679,18 +10648,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
-[[package]]
-name = "rusty-fork"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
-dependencies = [
- "fnv",
- "quick-error 1.2.3",
- "tempfile",
- "wait-timeout",
-]
-
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -12284,7 +12241,7 @@ dependencies = [
"fax",
"flate2",
"half",
- "quick-error 2.0.1",
+ "quick-error",
"weezl",
"zune-jpeg 0.5.15",
]
@@ -13191,15 +13148,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "wait-timeout"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "waker-fn"
version = "1.2.0"
diff --git a/crates/desktop/COMPONENTS.md b/crates/desktop/COMPONENTS.md
index 4bac70c9ab..d20ecb6c3b 100644
--- a/crates/desktop/COMPONENTS.md
+++ b/crates/desktop/COMPONENTS.md
@@ -19,143 +19,68 @@
# Desktop Components
-Phase 1 of the GPUI client: chat, sessions and settings, with the fewest components that make them usable.
+Phase 1 of the GPUI client: connect, list sessions, read history, send, stream the reply as Markdown, answer approvals.
## Direction
-- The reference is Waku's desktop client (look, density, behavior), not a port of the Electron app or Astryx. Waku is GPL: we study how it looks and behaves and implement it ourselves; no code is copied or translated.
-- Waku builds everything on raw GPUI, including its markdown renderer, text input, menus and scrollbar. We do not: those come from gpui-kit. We only write what gpui-kit lacks.
-- Colors are Maka's own: graphite neutrals and the Maka blue as the single accent.
+- The reference is Waku's desktop client: its look, density and behavior, and its choice to draw the transcript itself. Waku is GPL: we study how it behaves and implement it ourselves; no code is copied or translated.
+- Everything visible is ours: transcript, Markdown, sidebar, composer card, permission card, scrollbar, motion. gpui-kit supplies GPUI itself, `Root`, and the composer's `Textarea` (IME, undo and key handling are tested there and are costly to redo).
+- We first tried to build on gpui-kit's `MessageScroller` and `TextView`. Each gap was fixable (redraw rate of the fade, marker mending, pinning the prompt, pausing the follow), but every fix fought the component's ownership of the list and the parse. Owning both is less code than patching them, and it is what lets the pieces below share one clock, one selection and one list.
+- Colors are Maka's own (`DESIGN.md`): graphite neutrals, two text tiers, the Maka blue as the single accent.
-## Theme
+## Modules
-One gpui-kit `ThemeConfig` for light and one for dark. No token system of our own.
-
-- Surfaces: sidebar and canvas share one neutral; menus and dialogs use `popover`; code wells use `muted`.
-- Text: `foreground` and `muted_foreground`.
-- One border color; hover and selected are the same faint neutral wash; hover changes are instant.
-- Radius: 8 for controls and rows, 12 for bubbles, composer, cards and dialogs, pill for round buttons.
-- Arrow cursor everywhere, as native macOS apps do.
-- Reading column: 720 px, centered, shared by transcript and composer.
-
-## What gpui-kit already covers
-
-| Need | gpui-kit |
+| Module | Owns |
|---|---|
-| Transcript list that follows the tail, jump to latest | `MessageScroller` |
-| Markdown, code highlighting, selection, stream fade | `TextView` (fade needs the throttle fix below) |
-| User bubble | `Bubble` |
-| Copy button with check feedback | `Clipboard` |
-| Folding | `Collapsible` |
-| One-line status with spinner or shimmer | `Marker`, `ShimmerText` |
-| Composer text field that grows | `Textarea` with `appearance(false)` and `auto_grow` |
-| Question interaction | `Questionnaire` (on gpui-kit main, not in 0.6.6) |
-| Settings pages, forms, dialogs, toasts, menus, tooltips | `Settings`, `Form`, `Dialog`, `Notification`, `PopupMenu`, `Tooltip` |
-
-gpui-kit has no tool-call, diff or terminal component.
-
-## What we write
-
-| Component | Look and behavior | Built from |
-|---|---|---|
-| Session row | two lines: name, then muted workspace and relative time; status dot for running or waiting; one wash for hover and selected | own `SidebarItem` |
-| Tool row | one line: icon, verb, target, status (running, waiting, done, failed); expands to plain monospace output. Consecutive rows fold to "N commands"; a finished turn folds to "Worked for Ns" | `Collapsible` |
-| Working line | three dots in a travelling wave plus elapsed time; also shows retry and failure with resume | own, timer-driven |
-| Permission card | reason, command in a monospace well, deny and allow choices; pinned above the composer | `Button` |
-| Composer card | 12 radius card with a hairline border, borderless textarea, toolbar row, round send button that becomes stop while a turn runs | `Textarea`, `Button` |
-| Message hover footer | copy and time, shown only while the message is hovered | `Clipboard`, GPUI `group_hover` |
-
-Everything else in the window is layout: transparent titlebar, sidebar with a hairline divider, user bubbles right-aligned at most 540 wide, assistant text without bubble or avatar.
+| `theme` | Light and dark palettes from Maka's oklch values; synced into gpui-kit's theme for `Textarea` |
+| `ui` | `pressable`, buttons, icon buttons, tooltip, copy check mark, extra icons |
+| `ui::motion` | One ~30 Hz clock shared by every animation, leased per view, parked when idle |
+| `ui::select` | Transcript selection across rows and blocks; copy yields rendered text |
+| `ui::scrollbar` | Overlay scrollbar for a `list()` |
+| `md` | Streaming Markdown: parse to flat blocks, mend the tail, fade new text, render |
+| `chat` | Transcript rows and folding, tool summaries, composer, permission card |
+| `sidebar` | Sessions grouped by day |
+| `workspace` | Connection, stream batching, window layout |
-Code blocks keep gpui-kit's corner copy button. The sidebar is a solid color: GPUI's macOS blur covers the whole window with one fixed material.
+## Checklist
-Later, not phase 1: diff coloring, session groups, queued messages, model chip, context meter, attachments, OAuth.
+Problems Waku hit and solved, restated as checks on our code.
-## Motion
+### Transcript
-Animations redraw on a ~30 fps timer and stop when they finish; nothing redraws at display rate. gpui-kit's `request_animation_frame` drivers (stream fade, `Spinner`, `ShimmerText`) hold a ProMotion window at 120 Hz while visible, so the working line uses our own timer, and the stream fade runs with a local patch until the upstream fix lands.
+- While a turn runs, the sent prompt stays pinned at the top and the reply grows below it. End space below the last row fills the rest of the viewport; before the first measurement a full viewport is reserved so the prompt does not flash at the bottom. Once the reply fills the viewport, the list follows the tail.
+- A user scroll stops pinning and following; scrolling back onto the tail resumes following; dragging the scrollbar stops it for the whole drag.
+- A row that has not been measured has unknown height, not zero; the jump-to-latest button waits until it is known.
+- Rows have stable keys. A structural change splices only the changed range; streaming remeasures only the last rows.
+- A settled turn folds its work into one "工作了 N" line above the answer; a failed turn says so; the working line appears as soon as the prompt lands.
+- The hover footer is its own row, keeps its height when hidden, and copies the visible answer only.
-## Acceptance
+### Markdown
-Each written component appears in a gallery window (`maka-desktop --gallery`) in every state, light and dark; takes plain data, never protocol types; and causes no redraws while idle.
+- Unclosed `**`, `` ` ``, `~~` and half-typed links are closed or trimmed in a display copy of the last paragraph only; code is never mended. Tested on every prefix, and mending twice changes nothing.
+- Each update reparses from the last settled block, keeping the last two open; a link reference definition forces full parses. A streamed parse ends equal to a full parse.
+- The fade changes paint only, so wrapping, selection and row height stay put. It follows source arrival: after a rewrite only text past the common prefix fades; history opens fully opaque.
+- Selection survives rows scrolling out of view; copy separates blocks with a blank line.
-## Pitfall checklist
+### Composer and cards
-Problems Waku hit and solved, restated as checks. Waku started on gpui-component and later replaced it in the transcript and markdown; the gpui-kit items are checked against 0.6.6 in [Verification](#verification-gpui-kit-066). A failing item is fixed upstream where possible, otherwise covered here.
+- Enter sends; Shift-, Ctrl- and Alt-Enter insert a newline. The send button becomes stop while a turn runs.
+- The permission card sits above the composer, takes focus when it appears, and is keyboard reachable.
-### Transcript list (gpui-kit `MessageScroller`)
+### Accessibility
-- A row that has not been measured yet has unknown height, not zero. Reading it as zero makes the view jump between prompt and tail at stream speed and blinks the jump-to-latest button.
-- While a turn runs, the sent prompt stays pinned at the top and the reply grows below it; before the first measurement a full viewport of end space is reserved so the prompt does not flash at the bottom.
-- A user scroll stops following; scrolling back onto the tail resumes it; dragging the scrollbar stops it for the whole drag.
-- Streaming remeasures only the last few rows; a width change remeasures all; finishing a turn replaces exactly the changed rows so the list never shows row zero for a frame.
-- Expanding or collapsing a row first fixes the scroll position, so the header under the pointer does not move.
+Every component is done only when the macOS accessibility tree shows it with a role, a name and its state; it is also how components are checked without screenshots. GPUI reports an element only when it has both an id and a role.
-### Streaming markdown (gpui-kit `TextView`)
+- Done: the sidebar and main area are landmarks; every `pressable` is a button; the composer is a text input (gpui-kit's `Textarea`).
+- GPUI drops a cached view's nodes when it reuses the view's last frame, so views are drawn uncached while assistive technology is listening (`workspace::embed`). To report upstream.
+- To do: icon buttons need a name (reuse the tooltip text); session rows become list items with their position; transcript text is `StyledText`, which GPUI never reports, so each block needs an id and a role carrying its text (heading level, list item, code, table cells); the permission card is a labeled group; folds report expanded or collapsed.
-- Unclosed `**`, `` ` ``, `~~` and half-typed links are closed in a display copy of the tail only, never in the parsed document; code and math are never mended. Tested on every prefix of a corpus, and mending twice changes nothing.
-- Each delta reparses from the last stable block, keeping the last two blocks open (a table row or inline image can reshape the previous block); a link reference definition forces full parses.
-- The fade changes paint only (colors), never run lengths or fonts, so wrapping, selection and row height stay put. After a rewrite (`**bo` becoming bold) only the part past the common prefix fades; attaching to a stream in progress shows existing text at full opacity.
-- Selection spans blocks, survives rows scrolling out of view, and starts continuation rows at their first glyph (soft-wrap boundaries are walked, not looked up by index). Copy yields rendered text.
-- Streaming code is highlighted by a lexer that leaves the unfinished tail plain; highlighting never changes widths.
-- A long live reasoning block renders only its tail window.
-
-### Composer input (gpui-kit `Textarea`)
-
-- IME ranges relative to marked text are computed in UTF-16 before converting to bytes; tested with Chinese before the caret. Enter during composition commits the composition, it does not submit. Autocomplete and drafts ignore marked text.
-- Enter submits; Shift-, Ctrl- and Alt-Enter insert a newline. Modified deletes are bound explicitly, since GPUI swallows unbound chords.
-- Undo groups by gesture: paste, cut and completion start a step, a composition is one step, setting the text programmatically clears history, undo is refused while composing.
-- Owners react to an edited event, not to every notify (the caret blink notifies too).
-- Pasting follows clipboard order: image first attaches, file paths first attach, text first pastes text.
-
-### Overlays and focus
-
-- Focus a newly opened deferred overlay two frames later, guarded by a generation counter; focusing earlier silently fails.
-- A click on the trigger is exempt from click-outside dismissal, or the menu closes and reopens. Space typed inside a popover's field must not reach the trigger.
-- When the focused element disappears, focus returns to the composer.
-
-### Our components
+### Frame budget
-- **Tool rows**: a group stays live while it is the last block of the running turn and no answer text follows; live, its header shows the latest action, settled, a count summary. A finished turn folds all work into one "Worked for Ns" line above the answer.
-- **Working line**: appears as soon as the prompt lands, before any output.
-- **Hover footer**: its own row, so later tool activity cannot strand it; hidden actions keep their space so hover never changes height; hovering any row of a response shows it, the prompt does not. Copy takes the visible answer only. The copied check mark uses a generation counter so an older timer cannot clear a newer click.
-- **Permission card**: outside the list, above the composer. Keyboard reachable (Waku's is click-only; we do better). Requests from a turn that already ended are ignored.
-- **Session row**: fixed height including the gap, so scroll-to-selected works before rows are measured. Order by last reply, not name. A click selects immediately while the session loads. Escape in the rename field does not stop the turn.
-- **Relative time**: one timer set to the next moment any visible label changes (every second while busy), not a periodic refresh.
-- **Window chrome**: window drag starts on the first move after mouse down, so clicks and double-clicks still work; double-click follows the system setting.
-- **Diff and terminal** (later): diff built when expanded, dropped when collapsed, capped with a note; terminal output strips ANSI, including sequences split across chunks.
+- Stream updates batch for 120 ms: one parse, one notify, one remeasure per batch.
+- No animation redraws at display rate; the clock stops when nothing leases it and under reduce motion.
+- The sidebar and chat are cached views, so a streaming reply redraws the chat alone.
-### Frame budget
+## Later
-- Batch stream deltas for ~120 ms into one update, one notify, one remeasure, for every delta kind including reasoning.
-- No animation redraws at display rate; animations use a low-rate timer that stops when idle, and stay still under reduce motion.
-- Notify the smallest enclosing view; never refresh the whole window.
-- A cached view needs a flex parent, or its height collapses to zero.
-
-## Verification (gpui-kit 0.6.6)
-
-Our tests are in [tests/gpui_kit.rs](tests/gpui_kit.rs); other evidence is gpui-kit's or GPUI's own tests and source. Paths are relative to each crate's `src/`.
-
-| Check | Result | Evidence | Route |
-|---|---|---|---|
-| Unmeasured rows are unknown | Pass in GPUI; gpui-kit's `is_scrolled_up` counts unknown as scrolled up, so the jump button can show for a frame while following is off | gpui-pre `list.rs` `test_follow_tail_reengagement_not_fooled_by_unmeasured_items`; gpui-component `message_scroller.rs:58-63` | one-line upstream fix |
-| Prompt pinned at top while the reply grows | Missing: `MessageScroller` fixes alignment and follow mode and has no end space | gpui-component `message_scroller.rs:36-38` | trailing spacer row sized to the viewport, or an upstream option |
-| User scroll, scroll back, scrollbar drag | Pass | gpui-pre `list.rs` `test_follow_tail_disengages_on_user_scroll`, `…_on_scrollbar_reposition`, `…_reengages_when_scrolled_back_to_bottom`, `…_reengages_after_scrollbar_drag_to_bottom_while_growing` | use as is |
-| Remeasure only changed rows | Pass, when the caller remeasures the changed row | `remeasure_items`, `splice`; gpui-pre `test_remeasure_item_preserves_scroll_offset` | caller calls `remeasure_items` |
-| Expanding a row while following keeps it under the pointer | Missing: following snaps back to the tail; GPUI's `ListState::pause_following_tail` would hold it, but `MessageScrollerState` does not expose it | gpui-pre `elements/list.rs:646`; gpui-component `message_scroller.rs:26` | one-method upstream addition |
-| Unclosed markers hidden while streaming | Fail: `Hello **bol` renders `**` | `an_unclosed_emphasis_does_not_show_its_marker_while_streaming` (ignored) | upstream, or accept the literal markers: mending in the app makes the text non-append, which loses the fade |
-| Streamed parse equals full parse | Pass for tables, lists, fences | `a_streamed_table_renders_like_a_full_parse`, `a_streamed_list_and_fence_render_like_a_full_parse`; last block reparsed, gpui-base `text/state.rs:961` | use as is |
-| Fade is paint-only, refades past the common prefix, full opacity on attach | Pass | gpui-base `text/inline.rs:103`, `text/stream_fade.rs` `record`, `note_replace` | use as is |
-| Fade, `Spinner`, `ShimmerText` redraw rate | Fail: display rate while visible; the fade costs 3.5 s CPU per 17 s reply | measured on the spike; gpui-base `text/state.rs:761` | upstream (issue drafted); local patch meanwhile |
-| IME with multibyte text before the caret | Pass | gpui-base `input/base/state.rs` `test_ime_selection_is_relative_to_replacement_start` | use as is |
-| Undo per gesture, composition as one step | Pass | `test_ime_composition_undoes_as_one_unit`, `test_undo_manager_composition_cancel_leaves_no_entry`, `test_edit_after_composition_is_separate_undo` | use as is |
-| Enter family and modified deletes | Pass: `shift-`, `ctrl-`, `alt-backspace` bound; Enter submits or inserts by `submit_on_enter` | gpui-base `input/base/state.rs:133-150`, `:1887` | use as is |
-| Paste images and files | Hook exists; ordering is ours | gpui-component `input/textarea.rs:123` `on_paste` | composer |
-| An opened overlay handles its keys | Pass: Escape closes it once opened | `an_opened_popover_takes_focus_so_escape_closes_it` | use as is |
-| Trigger click closes an open popover | Pass | `clicking_the_trigger_of_an_open_popover_closes_it` | use as is |
-| Selection across views and blocks, copy in document order | Pass: a window-level selection layer joins text views in document order and caches their copies | gpui-base `text_selection.rs` `plain_projection_caches_multiple_participant_copies_in_document_order`, `cross_participant_selection_excludes_participants_outside_its_document_interval`, `shift_extension_falls_back_when_the_anchor_participant_was_swept` | wire the transcript into one selection scope |
-| Selection when its start scrolls out of view; soft-wrap row starts | Not tested; gpui-base falls back when the anchor view is swept, where Waku keeps the spans | | check by hand |
-| Enter while an IME composes | Not tested (OS input path) | | check by hand with Pinyin |
-| Streaming code highlight stability | Not tested; highlighting needs the `tree-sitter` feature | | decide when code blocks are styled |
-
-Nothing found forces the transcript or markdown off gpui-kit. The gaps are the fade rate, marker mending, prompt pinning and pausing the follow while a row expands, each fixable upstream or in the list's row content.
+Syntax highlighting, diffs, terminal output, queued messages, model chip, attachments, settings.
diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml
index 78e15c5734..653e01b22b 100644
--- a/crates/desktop/Cargo.toml
+++ b/crates/desktop/Cargo.toml
@@ -24,13 +24,13 @@ license.workspace = true
publish.workspace = true
[dependencies]
+chrono = { workspace = true, features = ["clock"] }
gpui-kit = "0.6.6"
maka-client.workspace = true
maka-event-log.workspace = true
maka-protocol.workspace = true
+pulldown-cmark.workspace = true
serde_json.workspace = true
tokio.workspace = true
+unicode-segmentation.workspace = true
uuid.workspace = true
-
-[dev-dependencies]
-gpui-kit = { version = "0.6.6", features = ["test-support"] }
diff --git a/crates/desktop/src/chat/composer.rs b/crates/desktop/src/chat/composer.rs
new file mode 100644
index 0000000000..7311098559
--- /dev/null
+++ b/crates/desktop/src/chat/composer.rs
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{Chat, Delivery};
+use crate::{
+ theme::theme,
+ ui::{self, icon},
+};
+use gpui_kit::{
+ Context, InteractiveElement, IntoElement, ParentElement, SharedString,
+ StatefulInteractiveElement, Styled, component::input::Textarea, div, prelude::FluentBuilder,
+ px,
+};
+
+impl Chat {
+ pub(super) fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ let theme = theme(cx);
+ let status: Option<SharedString> = match &self.delivery {
+ Some(Delivery::Failed(error)) => Some(format!("未发送:{error}").into()),
+ Some(Delivery::Unknown(error)) => {
+ Some(format!("发送结果未知,未自动重试:{error}").into())
+ }
+ _ => None,
+ };
+ let running = self.running().is_some();
+ let sending = matches!(self.delivery, Some(Delivery::Sending));
+ let draft = !self.composer.read(cx).value().trim().is_empty();
+ let blocked =
+ self.subscription.is_none() || matches!(self.delivery, Some(Delivery::Unknown(_)));
+ let this = cx.weak_entity();
+ let (path, filled, tip) = if running {
+ (
+ "icons/square.svg",
+ false,
+ if self.stopping {
+ "正在停止…"
+ } else {
+ "停止"
+ },
+ )
+ } else if sending {
+ ("icons/loader-circle.svg", false, "正在发送…")
+ } else {
+ ("icons/arrow-up.svg", draft && !blocked, "发送")
+ };
+ let action = div()
+ .id("send")
+ .size(px(26.))
+ .flex_none()
+ .rounded_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .border_1()
+ .border_color(gpui_kit::transparent_black())
+ .focus_visible(|style| style.border_color(theme.accent))
+ .map(|this| {
+ if filled {
+ this.bg(theme.accent_solid)
+ .hover(|style| style.opacity(0.9))
+ } else if running {
+ this.bg(theme.text.opacity(0.09))
+ .hover(|style| style.bg(theme.danger.opacity(0.14)))
+ } else {
+ this.bg(theme.text.opacity(0.09))
+ }
+ })
+ .child(
+ icon(
+ path,
+ if filled {
+ theme.on_accent
+ } else if running {
+ theme.text
+ } else {
+ theme.muted
+ },
+ )
+ .size(if running { px(12.) } else { px(16.) }),
+ )
+ .tooltip(ui::tooltip(tip));
+ let action = ui::pressable(action, move |window, cx| {
+ let _ = this.update(cx, |chat, cx| {
+ if chat.running().is_some() {
+ chat.stop(cx);
+ } else {
+ chat.send(window, cx);
+ }
+ });
+ });
+ div()
+ .w_full()
+ .flex()
+ .justify_center()
+ .px(px(20.))
+ .pb(px(16.))
+ .child(
+ div()
+ .w_full()
+ .max_w(px(super::view::COLUMN))
+ .flex()
+ .flex_col()
+ .gap(px(6.))
+ .when_some(status, |this, status| {
+ this.child(
+ div()
+ .px(px(4.))
+ .text_size(px(12.5))
+ .text_color(theme.danger)
+ .child(status),
+ )
+ })
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .rounded(px(13.))
+ .border_1()
+ .border_color(theme.border)
+ .bg(theme.raised)
+ .py(px(10.))
+ .child(
+ div().px(px(14.)).pt(px(2.)).child(
+ Textarea::new(&self.composer)
+ .appearance(false)
+ .text_size(px(13.5)),
+ ),
+ )
+ .child(
+ div()
+ .mt(px(8.))
+ .px(px(10.))
+ .flex()
+ .items_center()
+ .gap(px(4.))
+ .child(div().flex_1())
+ .child(action),
+ ),
+ ),
+ )
+ }
+}
diff --git a/crates/desktop/src/chat.rs b/crates/desktop/src/chat/mod.rs
similarity index 51%
rename from crates/desktop/src/chat.rs
rename to crates/desktop/src/chat/mod.rs
index c2bf51e9e7..1f82f24906 100644
--- a/crates/desktop/src/chat.rs
+++ b/crates/desktop/src/chat/mod.rs
@@ -17,25 +17,28 @@
* under the License.
*/
-use crate::host::Host;
-use gpui_kit::component::{
- ActiveTheme, Disableable, IconName, Sizable, StyledExt,
- bubble::{Bubble, BubbleContent, BubbleVariant},
- button::{Button, ButtonVariants},
- h_flex,
- input::{InputEvent, Textarea, TextareaState},
- message::{Message, MessageAlignment, MessageContent},
- message_scroller::{MessageScroller, MessageScrollerState},
- text::{TextView, TextViewState},
- v_flex,
+mod composer;
+mod permission;
+mod rows;
+mod tools;
+mod view;
+
+use crate::{
+ host::Host,
+ md::view::Body,
+ ui::{Copied, scrollbar::Scrollbar, select::Selection},
+};
+use gpui_kit::base::input::{InputEvent, TextareaState};
+use gpui_kit::{
+ AppContext, Context, Entity, FocusHandle, FollowMode, ListAlignment, ListOffset, ListState,
+ Pixels, SharedString, Subscription, Window, px,
};
-use gpui_kit::{prelude::FluentBuilder, *};
use maka_client::{
Client, RequestFailure,
transcript::{LiveText, TranscriptBatch},
};
use maka_protocol::{
- interaction::{self, InteractionAnswer, InteractionRequest, InteractionSnapshot},
+ interaction::{InteractionAnswer, InteractionSnapshot},
message::{Placement, SubmitInput, SubmitResult},
subscription::{
AssistantObservationFrame, AssistantStreamKind, ObservationFrame,
@@ -43,29 +46,21 @@ use maka_protocol::{
SubscriptionClosedReason, SubscriptionOpenInput, TranscriptAdvancedFrame, TranscriptPolicy,
},
transcript::{SessionTranscriptPageDirection, SessionTranscriptPageInput},
- turn::{MessageContent as TurnContent, TurnState},
+ turn::{MessageContent as TurnContent, TurnState, TurnStopInput},
+};
+use rows::{Entry, Row, Transcript};
+use serde_json::Value;
+use std::{
+ collections::{BTreeMap, HashMap, HashSet},
+ time::{Duration, Instant},
};
-use serde_json::{Value, json};
-use std::collections::{BTreeMap, HashMap};
const TAIL_BYTES: u64 = 16 * 1024;
const PAGE_BYTES: u64 = 64 * 1024;
+/// Streaming changes the height of the reply and of the rows around it.
+const TAIL_ROWS: usize = 3;
-#[derive(Clone, PartialEq)]
-enum Kind {
- User,
- Assistant,
- Thinking,
- Tool,
- Failure,
-}
-
-#[derive(Clone, PartialEq)]
-struct Item {
- key: SharedString,
- kind: Kind,
- text: SharedString,
-}
+gpui_kit::actions!(transcript, [CopySelection]);
enum Delivery {
Sending,
@@ -73,6 +68,13 @@ enum Delivery {
Unknown(SharedString),
}
+/// Keeps a just-sent prompt at the top of the view while its reply grows
+/// below it, until the reply fills the view and the list follows its end.
+struct Anchor {
+ row: String,
+ pinning: bool,
+}
+
pub struct Chat {
client: Client,
session: String,
@@ -84,13 +86,35 @@ pub struct Chat {
paging: bool,
live: Vec<(SessionAssistantStreamIdentity, LiveText)>,
snapshot: Option<SessionObservationSnapshot>,
- items: Vec<Item>,
- markdown: HashMap<SharedString, (Entity<TextViewState>, SharedString)>,
- scroller: Entity<MessageScrollerState>,
+ transcript: Transcript,
+ fold: Vec<Row>,
+ keys: Vec<String>,
+ bodies: HashMap<String, Body>,
+ list: ListState,
+ scrollbar: Scrollbar,
+ selection: Selection,
+ copied: Entity<Copied>,
+ /// Settled turns whose folded work is shown.
+ open_turns: HashSet<String>,
+ /// Groups and calls the reader opened or closed, overriding the default.
+ toggled: HashMap<String, bool>,
+ hovered_turn: Option<String>,
+ sent: Option<String>,
+ anchor: Option<Anchor>,
+ end_space: Pixels,
+ jump: bool,
composer: Entity<TextareaState>,
delivery: Option<Delivery>,
+ stopping: bool,
answering: Option<String>,
interaction_error: Option<SharedString>,
+ focus: FocusHandle,
+ permission_focus: FocusHandle,
+ focused_interaction: Option<String>,
+ /// Animation time for this frame; `None` when nothing moves or motion
+ /// is reduced.
+ elapsed: Option<Duration>,
+ now: Option<Instant>,
_subscriptions: Vec<Subscription>,
}
@@ -98,23 +122,38 @@ impl Chat {
pub fn new(
client: Client,
session: String,
+ copied: Entity<Copied>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
- let scroller = cx.new(|cx| MessageScrollerState::new(0, cx));
+ let list = ListState::new(0, ListAlignment::Top, px(2048.));
+ list.set_follow_mode(FollowMode::Tail);
+ let weak = cx.weak_entity();
+ list.set_scroll_handler(move |event, _, cx| {
+ if !event.is_scrolled {
+ return;
+ }
+ let _ = weak.update(cx, |chat, cx| {
+ if let Some(anchor) = &mut chat.anchor
+ && anchor.pinning
+ {
+ anchor.pinning = false;
+ cx.notify();
+ }
+ });
+ });
let composer = cx.new(|cx| {
TextareaState::new(window, cx)
- .auto_grow(1, 8)
- .placeholder("给 Maka 发消息,⌘↩ 发送")
+ .auto_grow(1, 13)
+ .submit_on_enter(true)
+ .placeholder("给 Maka 发消息")
});
let submit = cx.subscribe_in(&composer, window, |this, _, event, window, cx| {
- if let InputEvent::PressEnter {
- secondary: true, ..
- } = event
- {
+ if let InputEvent::PressEnter { shift: false, .. } = event {
this.send(window, cx);
}
});
+ let redraw = cx.observe(&copied, |_, _, cx| cx.notify());
let mut this = Self {
client,
session,
@@ -126,14 +165,32 @@ impl Chat {
paging: false,
live: Vec::new(),
snapshot: None,
- items: Vec::new(),
- markdown: HashMap::new(),
- scroller,
+ transcript: Transcript::default(),
+ fold: Vec::new(),
+ keys: Vec::new(),
+ bodies: HashMap::new(),
+ list,
+ scrollbar: Scrollbar::default(),
+ selection: Selection::default(),
+ copied,
+ open_turns: HashSet::new(),
+ toggled: HashMap::new(),
+ hovered_turn: None,
+ sent: None,
+ anchor: None,
+ end_space: px(0.),
+ jump: false,
composer,
delivery: None,
+ stopping: false,
answering: None,
interaction_error: None,
- _subscriptions: vec![submit],
+ focus: cx.focus_handle(),
+ permission_focus: cx.focus_handle(),
+ focused_interaction: None,
+ elapsed: None,
+ now: None,
+ _subscriptions: vec![submit, redraw],
};
this.open(cx);
this
@@ -143,6 +200,11 @@ impl Chat {
&self.session
}
+ pub fn focus_composer(&self, window: &mut Window, cx: &mut Context<Self>) {
+ self.composer
+ .update(cx, |composer, cx| composer.focus(window, cx));
+ }
+
fn open(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
let session = self.session.clone();
@@ -179,8 +241,9 @@ impl Chat {
let _ = this.update(cx, |this, cx| match result {
Ok((opened, batch)) => {
this.snapshot = Some(opened.snapshot);
- this.install(batch, cx);
+ this.install(batch);
this.through = this.wanted;
+ this.rebuild(cx);
// The Host starts pushing frames only after ready, so the
// snapshot above must already be installed.
this.subscription = Some(opened.subscription_id.clone());
@@ -225,7 +288,7 @@ impl Chat {
}
}
- fn install(&mut self, batch: TranscriptBatch, cx: &mut Context<Self>) {
+ fn install(&mut self, batch: TranscriptBatch) {
for row in batch.rows {
if row.value["type"] == "assistant" {
let id = row.value["id"].as_str().unwrap_or_default();
@@ -236,7 +299,6 @@ impl Chat {
if let Some(through) = batch.through_sequence {
self.wanted = self.wanted.max(Some(through));
}
- self.rebuild(cx);
}
pub fn accept(&mut self, frames: Vec<ObservationFrame>, cx: &mut Context<Self>) {
@@ -249,6 +311,7 @@ impl Chat {
ObservationFrame::Projection(frame) => {
let SessionProjectionFrame::SessionProjection { snapshot, .. } = *frame;
self.snapshot = Some(snapshot);
+ self.stopping &= self.running().is_some();
changed = true;
}
ObservationFrame::Transcript(TranscriptAdvancedFrame::TranscriptAdvanced {
@@ -356,9 +419,10 @@ impl Chat {
match result {
Ok(batches) => {
for batch in batches {
- this.install(batch, cx);
+ this.install(batch);
}
this.through = this.through.max(Some(wanted));
+ this.rebuild(cx);
this.fetch_newer(cx);
}
Err(error) => {
@@ -371,54 +435,184 @@ impl Chat {
.detach();
}
+ /// The live root turn, if one is running.
+ fn running(&self) -> Option<&maka_protocol::turn::TurnSnapshot> {
+ self.snapshot
+ .as_ref()
+ .and_then(|snapshot| snapshot.root_turn.as_ref())
+ .filter(|turn| {
+ matches!(
+ turn.state,
+ TurnState::Admitted(_)
+ | TurnState::Created(_)
+ | TurnState::Running(_)
+ | TurnState::WaitingForUser(_)
+ )
+ })
+ }
+
fn rebuild(&mut self, cx: &mut Context<Self>) {
- let mut items: Vec<Item> = self.rows.values().filter_map(project).collect();
- items.extend(
- self.live
- .iter()
- .filter(|(_, text)| !text.text.is_empty())
- .map(|(stream, text)| Item {
- key: message_key(&stream.message_id, stream.kind).into(),
- kind: match stream.kind {
- AssistantStreamKind::Text => Kind::Assistant,
- AssistantStreamKind::Thinking => Kind::Thinking,
- },
- text: text.text.clone().into(),
- }),
- );
- for item in items.iter().filter(|item| item.kind == Kind::Assistant) {
- match self.markdown.get_mut(&item.key) {
- Some((_, text)) if *text == item.text => {}
- Some((state, text)) => {
- let extended = item.text.strip_prefix(text.as_str()).map(str::to_owned);
- state.update(cx, |state, cx| match extended {
- Some(suffix) => state.push_str(&suffix, cx),
- None => state.set_text(&item.text, cx),
- });
- *text = item.text.clone();
- }
+ let now = Instant::now();
+ let live = self
+ .live
+ .iter()
+ .filter(|(_, text)| !text.text.is_empty())
+ .map(|(stream, text)| match stream.kind {
+ AssistantStreamKind::Text => Entry::Text {
+ id: stream.message_id.clone(),
+ turn: stream.turn_id.clone(),
+ text: text.text.clone(),
+ streaming: true,
+ },
+ AssistantStreamKind::Thinking => Entry::Thought {
+ id: stream.message_id.clone(),
+ turn: stream.turn_id.clone(),
+ text: text.text.clone(),
+ streaming: true,
+ },
+ });
+ self.transcript = rows::transcript(self.rows.values(), live.collect::<Vec<_>>());
+ let running = self.running().map(|turn| turn.turn_id.clone());
+ let fold = rows::fold(&self.transcript, running.as_deref(), &self.open_turns);
+ let keys: Vec<String> = fold
+ .iter()
+ .map(|row| row.key(&self.transcript.entries))
+ .collect();
+
+ let mut seen = HashSet::new();
+ for entry in &self.transcript.entries {
+ let (key, text, streaming) = match entry {
+ Entry::Prompt { id, text, .. } => (format!("prompt:{id}"), text, false),
+ Entry::Text {
+ id,
+ text,
+ streaming,
+ ..
+ } => (format!("text:{id}"), text, *streaming),
+ Entry::Thought {
+ id,
+ text,
+ streaming,
+ ..
+ } => (format!("thought:{id}"), text, *streaming),
+ Entry::Tool { .. } => continue,
+ };
+ match self.bodies.get_mut(&key) {
+ Some(body) => body.update(text, streaming, now),
None => {
- let state = cx.new(|cx| TextViewState::markdown(&item.text, cx));
- self.markdown
- .insert(item.key.clone(), (state, item.text.clone()));
+ self.bodies.insert(key.clone(), Body::new(text, streaming));
}
}
+ seen.insert(key);
}
- let old = std::mem::replace(&mut self.items, items);
- let same_prefix = old.len() <= self.items.len()
- && old.iter().zip(&self.items).all(|(a, b)| a.key == b.key);
- self.scroller.update(cx, |scroller, cx| {
- if !same_prefix {
- scroller.reset(self.items.len(), cx);
- return;
- }
- if let Some(changed) = old.iter().zip(&self.items).position(|(a, b)| a != b) {
- scroller.remeasure_items(changed..old.len(), cx);
- }
- if self.items.len() > old.len() {
- scroller.append(self.items.len() - old.len(), cx);
+ self.bodies.retain(|key, _| seen.contains(key));
+
+ if let Some(sent) = &self.sent {
+ let row = format!("prompt:{sent}");
+ if keys.contains(&row) {
+ self.sent = None;
+ self.anchor = Some(Anchor { row, pinning: true });
+ // Room for the reply before anything is measured, so the
+ // prompt never shows at the bottom first.
+ self.end_space = self.list.viewport_bounds().size.height;
+ self.list.pause_following_tail();
}
- });
+ }
+
+ let old = std::mem::replace(&mut self.keys, keys);
+ let prefix = old
+ .iter()
+ .zip(&self.keys)
+ .take_while(|(a, b)| a == b)
+ .count();
+ let suffix = old[prefix..]
+ .iter()
+ .rev()
+ .zip(self.keys[prefix..].iter().rev())
+ .take_while(|(a, b)| a == b)
+ .count();
+ if prefix + suffix < old.len().max(self.keys.len()) {
+ self.list.splice(
+ prefix..old.len() - suffix,
+ self.keys.len() - prefix - suffix,
+ );
+ }
+ let tail = self.keys.len().saturating_sub(TAIL_ROWS)..self.keys.len();
+ if !tail.is_empty() {
+ self.list.remeasure_items(tail);
+ }
+ self.fold = fold;
+ cx.notify();
+ }
+
+ /// Keeps the list where the reader is while a row changes height under
+ /// them, instead of snapping to the end.
+ fn toggle(&mut self, key: String, open: bool, row: usize, cx: &mut Context<Self>) {
+ self.list.pause_following_tail();
+ if let Some(anchor) = &mut self.anchor {
+ anchor.pinning = false;
+ }
+ self.toggled.insert(key, open);
+ self.list.remeasure_items(row..row + 1);
+ cx.notify();
+ }
+
+ fn toggle_turn(&mut self, turn: String, cx: &mut Context<Self>) {
+ self.list.pause_following_tail();
+ if let Some(anchor) = &mut self.anchor {
+ anchor.pinning = false;
+ }
+ if !self.open_turns.remove(&turn) {
+ self.open_turns.insert(turn);
+ }
+ self.rebuild(cx);
+ }
+
+ /// Before layout: size the room under the anchored prompt and hold the
+ /// prompt at the top while that room remains.
+ fn place(&mut self, window: &Window) {
+ let Some(anchor) = &mut self.anchor else {
+ self.end_space = px(0.);
+ return;
+ };
+ let Some(ix) = self.keys.iter().position(|key| *key == anchor.row) else {
+ self.anchor = None;
+ self.end_space = px(0.);
+ return;
+ };
+ let viewport = match self.list.viewport_bounds().size.height {
+ height if height > px(0.) => height,
+ _ => window.viewport_size().height,
+ };
+ let last = self.keys.len() - 1;
+ // Unmeasured rows are unknown, not empty: keep the last answer.
+ if let (Some(top), Some(bottom)) = (
+ self.list.bounds_for_item(ix),
+ self.list.bounds_for_item(last),
+ ) {
+ self.end_space = (viewport - (bottom.bottom() - top.top())).max(px(0.));
+ }
+ if !anchor.pinning {
+ return;
+ }
+ if self.end_space > px(0.) {
+ self.list.pause_following_tail();
+ self.list.scroll_to(ListOffset {
+ item_ix: ix,
+ offset_in_item: px(0.),
+ });
+ } else {
+ anchor.pinning = false;
+ self.list.set_follow_mode(FollowMode::Tail);
+ }
+ }
+
+ fn jump_to_latest(&mut self, cx: &mut Context<Self>) {
+ if let Some(anchor) = &mut self.anchor {
+ anchor.pinning = true;
+ }
+ self.list.set_follow_mode(FollowMode::Tail);
+ self.jump = false;
cx.notify();
}
@@ -426,7 +620,8 @@ impl Chat {
if matches!(
self.delivery,
Some(Delivery::Sending | Delivery::Unknown(_))
- ) {
+ ) || self.subscription.is_none()
+ {
return;
}
let text = self.composer.read(cx).value().to_string();
@@ -435,10 +630,11 @@ impl Chat {
}
self.delivery = Some(Delivery::Sending);
let client = self.client.clone();
+ let message_id = uuid::Uuid::new_v4().to_string();
let input = SubmitInput {
origin_host_epoch: client.identity.host_epoch.clone(),
session_id: self.session.clone(),
- message_id: uuid::Uuid::new_v4().to_string(),
+ message_id: message_id.clone(),
content: TurnContent {
text: text.clone(),
display_text: None,
@@ -451,6 +647,7 @@ impl Chat {
input_selections: Default::default(),
turn_orchestration: None,
};
+ self.sent = Some(message_id);
let sending = cx
.global::<Host>()
.spawn(async move { client.submit_message(input).await });
@@ -475,6 +672,40 @@ impl Chat {
Ok(Err(error)) => Some(Delivery::Unknown(error.to_string().into())),
Err(error) => Some(Delivery::Unknown(error.into())),
};
+ if this.delivery.is_some() {
+ this.sent = None;
+ }
+ cx.notify();
+ });
+ })
+ .detach();
+ cx.notify();
+ }
+
+ fn stop(&mut self, cx: &mut Context<Self>) {
+ let Some(turn) = self.running() else {
+ return;
+ };
+ let input = TurnStopInput {
+ session_id: turn.session_id.clone(),
+ turn_id: turn.turn_id.clone(),
+ run_id: turn.run_id.clone(),
+ };
+ if self.stopping {
+ return;
+ }
+ self.stopping = true;
+ let client = self.client.clone();
+ let stopping = cx
+ .global::<Host>()
+ .spawn(async move { client.stop_turn(input).await });
+ cx.spawn(async move |this, cx| {
+ let result = stopping.await.and_then(|r| r.map_err(|e| e.to_string()));
+ let _ = this.update(cx, |this, cx| {
+ if let Err(error) = result {
+ this.stopping = false;
+ this.error = Some(format!("无法停止:{error}").into());
+ }
cx.notify();
});
})
@@ -511,313 +742,9 @@ impl Chat {
cx.notify();
}
- fn running(&self) -> bool {
- self.snapshot
- .as_ref()
- .and_then(|snapshot| snapshot.root_turn.as_ref())
- .is_some_and(|turn| {
- matches!(
- turn.state,
- TurnState::Admitted(_) | TurnState::Created(_) | TurnState::Running(_)
- )
- })
- }
-
- fn render_item(&self, ix: usize, cx: &App) -> AnyElement {
- let Some(item) = self.items.get(ix) else {
- return div().into_any_element();
- };
- let body = match item.kind {
- Kind::User => Message::new()
- .alignment(MessageAlignment::End)
- .content(
- MessageContent::new().bubble(
- Bubble::new()
- .with_variant(BubbleVariant::Secondary)
- .content(BubbleContent::new().child(item.text.clone())),
- ),
- )
- .into_any_element(),
- Kind::Assistant => match self.markdown.get(&item.key) {
- Some((state, _)) => TextView::new(state).selectable(true).into_any_element(),
- None => div().child(item.text.clone()).into_any_element(),
- },
- Kind::Thinking => div()
- .text_sm()
- .text_color(cx.theme().muted_foreground)
- .child(item.text.clone())
- .into_any_element(),
- Kind::Tool => div()
- .text_sm()
- .font_family(cx.theme().mono_font_family.clone())
- .text_color(cx.theme().muted_foreground)
- .child(item.text.clone())
- .into_any_element(),
- Kind::Failure => div()
- .text_sm()
- .text_color(cx.theme().danger)
- .child(item.text.clone())
- .into_any_element(),
- };
- div()
- .id(ElementId::Name(item.key.clone()))
- .px_6()
- .py_2()
- .child(body)
- .into_any_element()
- }
-
- fn render_interaction(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
- let pending = self
- .snapshot
- .as_ref()?
- .interactions
- .pending()
- .first()?
- .clone();
- let busy = self.answering.as_deref() == Some(pending.interaction_id());
- let (title, detail, choices): (&str, String, Vec<(&str, &str, Value)>) = match pending
- .request()
- {
- InteractionRequest::Permissions {
- tool_use_id,
- request,
- ..
- } => {
- let allow = |scope: &str| json!({"decision": "allow", "permissions": request.permissions, "scope": scope});
- let mut choices = vec![("deny", "拒绝", json!({"decision": "deny"}))];
- if tool_use_id.is_some() {
- choices.push(("once", "允许一次", allow("once")));
- }
- choices.push(("session", "本会话允许", allow("session")));
- let detail = match &request.command {
- Some(command) => format!(
- "{}\n\n$ {}\n (cwd: {})",
- request.reason, command.command, command.cwd
- ),
- None => request.reason.clone(),
- };
- (
- "需要额外权限",
- detail,
- choices
- .into_iter()
- .map(|(id, label, decision)| {
- (
- id,
- label,
- json!({"kind": "permissions", "decision": decision}),
- )
- })
- .collect(),
- )
- }
- InteractionRequest::ClientCapability { target, .. } => (
- "客户端能力请求",
- serde_json::to_string_pretty(target).unwrap_or_default(),
- vec![
- (
- "deny",
- "拒绝",
- json!({"kind": "client_capability", "decision": "deny"}),
- ),
- (
- "allow",
- "允许",
- json!({"kind": "client_capability", "decision": "allow"}),
- ),
- ],
- ),
- _ => ("有待回答的问题", "请在其他客户端中回答。".into(), vec![]),
- };
- let buttons = choices
- .into_iter()
- .enumerate()
- .map(|(ix, (id, label, value))| {
- let pending = pending.clone();
- let button =
- Button::new(SharedString::from(format!("interaction-{id}")))
- .label(label)
- .small()
- .disabled(busy)
- .on_click(cx.listener(
- move |this, _, _, cx| match interaction::decode_answer(&value) {
- Ok(answer) => this.answer(pending.clone(), answer, cx),
- Err(error) => {
- this.interaction_error = Some(error.to_string().into());
- cx.notify();
- }
- },
- ));
- // Choices run from deny to the broadest grant; the default is the narrowest allow.
- if ix == 1 { button.primary() } else { button }
- });
- Some(
- v_flex()
- .mx_6()
- .mb_3()
- .p_4()
- .gap_3()
- .rounded(cx.theme().radius_lg)
- .border_1()
- .border_color(cx.theme().border)
- .bg(cx.theme().secondary)
- .child(div().font_semibold().child(title))
- .child(
- div()
- .text_sm()
- .font_family(cx.theme().mono_font_family.clone())
- .child(detail),
- )
- .when_some(self.interaction_error.clone(), |this, error| {
- this.child(div().text_sm().text_color(cx.theme().danger).child(error))
- })
- .child(h_flex().justify_end().gap_2().children(buttons))
- .into_any_element(),
- )
- }
-
- fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
- let sending = matches!(self.delivery, Some(Delivery::Sending));
- let status: Option<(SharedString, bool)> = match &self.delivery {
- Some(Delivery::Failed(error)) => Some((format!("未发送:{error}").into(), true)),
- Some(Delivery::Unknown(error)) => {
- Some((format!("发送结果未知,未自动重试:{error}").into(), true))
- }
- _ if self.running() => Some(("正在回复…".into(), false)),
- _ => None,
- };
- v_flex()
- .px_6()
- .pb_4()
- .gap_2()
- .when_some(status, |this, (text, error)| {
- this.child(
- div()
- .text_sm()
- .text_color(if error {
- cx.theme().danger
- } else {
- cx.theme().muted_foreground
- })
- .child(text),
- )
- })
- .child(
- h_flex()
- .items_end()
- .gap_2()
- .child(
- div()
- .flex_1()
- .min_w_0()
- .child(Textarea::new(&self.composer)),
- )
- .child(
- Button::new("send")
- .primary()
- .icon(IconName::ArrowUp)
- .tooltip("发送 ⌘↩")
- .loading(sending)
- .disabled(
- self.subscription.is_none()
- || matches!(self.delivery, Some(Delivery::Unknown(_))),
- )
- .on_click(cx.listener(|this, _, window, cx| this.send(window, cx))),
- ),
- )
- }
-}
-
-impl Render for Chat {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let chat = cx.weak_entity();
- v_flex()
- .size_full()
- .when_some(self.error.clone(), |this, error| {
- this.child(
- div()
- .px_6()
- .py_2()
- .text_sm()
- .text_color(cx.theme().danger)
- .child(error),
- )
- })
- .child(
- div().flex_1().min_h_0().child(
- MessageScroller::new("transcript", self.scroller.clone(), move |ix, _, cx| {
- chat.upgrade()
- .map(|chat| chat.read(cx).render_item(ix, cx))
- .unwrap_or_else(|| div().into_any_element())
- })
- .with_jump_button_label("回到最新")
- .size_full(),
- ),
- )
- .children(self.render_interaction(cx))
- .child(self.render_composer(cx))
- }
-}
-
-fn message_key(message_id: &str, kind: AssistantStreamKind) -> String {
- match kind {
- AssistantStreamKind::Text => format!("assistant:{message_id}"),
- AssistantStreamKind::Thinking => format!("thinking:{message_id}"),
+ fn copy_selection(&mut self, _: &CopySelection, _: &mut Window, cx: &mut Context<Self>) {
+ if let Some(text) = self.selection.copied() {
+ cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text));
+ }
}
}
-
-fn project(row: &Value) -> Option<Item> {
- let id = row["id"].as_str().unwrap_or_default();
- let text = |value: &Value| {
- value
- .as_str()
- .filter(|text| !text.trim().is_empty())
- .map(str::to_owned)
- };
- let (key, kind, text) = match row["type"].as_str()? {
- "user" => (
- format!("user:{id}"),
- Kind::User,
- text(&row["displayText"]).or_else(|| text(&row["text"]))?,
- ),
- "assistant" => match text(&row["text"]) {
- Some(body) => (
- message_key(id, AssistantStreamKind::Text),
- Kind::Assistant,
- body,
- ),
- None => (
- message_key(id, AssistantStreamKind::Thinking),
- Kind::Thinking,
- text(&row["thinking"]["text"])?,
- ),
- },
- "tool_call" => (
- format!("tool:{id}"),
- Kind::Tool,
- format!(
- "▸ {} {}",
- row["toolName"].as_str().unwrap_or("tool"),
- row["args"]["command"]
- .as_str()
- .map(str::to_owned)
- .unwrap_or_else(|| row["args"].to_string())
- ),
- ),
- "turn_state" if row["status"] == "failed" => (
- format!("failure:{id}"),
- Kind::Failure,
- format!(
- "运行失败:{}",
- row["failureMessage"].as_str().unwrap_or("未知错误")
- ),
- ),
- _ => return None,
- };
- Some(Item {
- key: key.into(),
- kind,
- text: text.into(),
- })
-}
diff --git a/crates/desktop/src/chat/permission.rs b/crates/desktop/src/chat/permission.rs
new file mode 100644
index 0000000000..af4f0baedf
--- /dev/null
+++ b/crates/desktop/src/chat/permission.rs
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! The card that asks before the agent acts, between the transcript and
+//! the composer. Its first allow choice takes focus when it appears, so
+//! Enter answers it and Tab moves between the choices.
+
+use super::Chat;
+use crate::{
+ theme::theme,
+ ui::{Tone, button, icon},
+};
+use gpui_kit::{
+ AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
+ StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
+};
+use maka_protocol::interaction::{self, InteractionRequest};
+use serde_json::{Value, json};
+
+impl Chat {
+ pub(super) fn render_permission(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<AnyElement> {
+ let pending = self
+ .snapshot
+ .as_ref()?
+ .interactions
+ .pending()
+ .first()?
+ .clone();
+ let id = pending.interaction_id().to_owned();
+ if self.focused_interaction.as_deref() != Some(id.as_str()) {
+ self.focused_interaction = Some(id.clone());
+ let handle = self.permission_focus.clone();
+ window.on_next_frame(move |window, cx| window.focus(&handle, cx));
+ }
+ let busy = self.answering.as_deref() == Some(id.as_str());
+ let (title, detail, choices): (&str, String, Vec<(&str, &str, Value)>) = match pending
+ .request()
+ {
+ InteractionRequest::Permissions {
+ tool_use_id,
+ request,
+ ..
+ } => {
+ let allow = |scope: &str| json!({"decision": "allow", "permissions": request.permissions, "scope": scope});
+ let mut choices = Vec::new();
+ if tool_use_id.is_some() {
+ choices.push(("once", "允许一次", allow("once")));
+ }
+ choices.push(("session", "本会话允许", allow("session")));
+ choices.push(("deny", "拒绝", json!({"decision": "deny"})));
+ let detail = match &request.command {
+ Some(command) => format!("{}\n$ {}", request.reason, command.command),
+ None => request.reason.clone(),
+ };
+ (
+ "需要额外权限",
+ detail,
+ choices
+ .into_iter()
+ .map(|(id, label, decision)| {
+ (
+ id,
+ label,
+ json!({"kind": "permissions", "decision": decision}),
+ )
+ })
+ .collect(),
+ )
+ }
+ InteractionRequest::ClientCapability { target, .. } => (
+ "客户端能力请求",
+ serde_json::to_string_pretty(target).unwrap_or_default(),
+ vec![
+ (
+ "allow",
+ "允许",
+ json!({"kind": "client_capability", "decision": "allow"}),
+ ),
+ (
+ "deny",
+ "拒绝",
+ json!({"kind": "client_capability", "decision": "deny"}),
+ ),
+ ],
+ ),
+ _ => ("有待回答的问题", "请在其他客户端中回答。".into(), vec![]),
+ };
+ let theme = theme(cx);
+ let this = cx.weak_entity();
+ let buttons = choices
+ .into_iter()
+ .enumerate()
+ .map(|(ix, (choice, label, value))| {
+ let pending = pending.clone();
+ let this = this.clone();
+ // Choices run from the narrowest allow to deny; the first one is the default.
+ let tone = if ix == 0 {
+ Tone::Primary
+ } else {
+ Tone::Outline
+ };
+ let control = button(
+ SharedString::from(format!("interaction-{choice}")),
+ label,
+ tone,
+ busy,
+ move |_, cx| {
+ let _ =
+ this.update(cx, |chat, cx| match interaction::decode_answer(&value) {
+ Ok(answer) => chat.answer(pending.clone(), answer, cx),
+ Err(error) => {
+ chat.interaction_error = Some(error.to_string().into());
+ cx.notify();
+ }
+ });
+ },
+ cx,
+ );
+ if ix == 0 {
+ control.track_focus(&self.permission_focus)
+ } else {
+ control
+ }
+ });
+ Some(
+ div()
+ .w_full()
+ .flex()
+ .justify_center()
+ .px(px(20.))
+ .pb(px(8.))
+ .child(
+ div()
+ .w_full()
+ .max_w(px(super::view::COLUMN))
+ .p(px(12.))
+ .flex()
+ .flex_col()
+ .rounded(px(12.))
+ .border_1()
+ .border_color(theme.border)
+ .bg(theme.raised)
+ .shadow_md()
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap(px(8.))
+ .child(
+ icon("icons/triangle-alert.svg", theme.warning).size(px(13.)),
+ )
+ .child(
+ div()
+ .text_size(px(12.5))
+ .font_weight(FontWeight::MEDIUM)
+ .child(title),
+ ),
+ )
+ .child(
+ div()
+ .id("interaction-detail")
+ .mt(px(8.))
+ .max_h(px(92.))
+ .overflow_y_scroll()
+ .p(px(8.))
+ .rounded(px(7.))
+ .bg(theme.sunken)
+ .font_family(theme.mono_font.clone())
+ .text_size(px(12.5))
+ .line_height(px(16.))
+ .text_color(theme.muted)
+ .child(detail),
+ )
+ .when_some(self.interaction_error.clone(), |this, error| {
+ this.child(
+ div()
+ .mt(px(8.))
+ .text_size(px(12.5))
+ .text_color(theme.danger)
+ .child(error),
+ )
+ })
+ .child(div().mt(px(10.)).flex().gap(px(8.)).children(buttons)),
+ )
+ .into_any_element(),
+ )
+ }
+}
diff --git a/crates/desktop/src/chat/rows.rs b/crates/desktop/src/chat/rows.rs
new file mode 100644
index 0000000000..fcc0bafed7
--- /dev/null
+++ b/crates/desktop/src/chat/rows.rs
@@ -0,0 +1,448 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! What the transcript shows, as plain data: the Host's rows and live
+//! streams become entries, and entries fold into list rows. A settled turn
+//! keeps its prompt and final answer in view and folds the work before the
+//! answer under one "worked for" line.
+
+use serde_json::Value;
+use std::collections::{BTreeMap, HashSet};
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Entry {
+ Prompt {
+ id: String,
+ turn: String,
+ text: String,
+ at: u64,
+ },
+ Text {
+ id: String,
+ turn: String,
+ text: String,
+ streaming: bool,
+ },
+ Thought {
+ id: String,
+ turn: String,
+ text: String,
+ streaming: bool,
+ },
+ Tool {
+ id: String,
+ turn: String,
+ name: String,
+ args: Value,
+ result: Option<Outcome>,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Outcome {
+ pub failed: bool,
+ pub output: String,
+}
+
+impl Entry {
+ pub fn id(&self) -> &str {
+ match self {
+ Self::Prompt { id, .. }
+ | Self::Text { id, .. }
+ | Self::Thought { id, .. }
+ | Self::Tool { id, .. } => id,
+ }
+ }
+
+ pub fn turn(&self) -> &str {
+ match self {
+ Self::Prompt { turn, .. }
+ | Self::Text { turn, .. }
+ | Self::Thought { turn, .. }
+ | Self::Tool { turn, .. } => turn,
+ }
+ }
+
+ /// Text that is only whitespace is work, not an answer.
+ fn answers(&self) -> bool {
+ matches!(self, Self::Text { text, .. } if !text.trim().is_empty())
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Ending {
+ Completed,
+ Failed(String),
+ Cancelled,
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Turn {
+ pub started: Option<u64>,
+ pub ended: Option<(u64, Ending)>,
+}
+
+#[derive(Default)]
+pub struct Transcript {
+ pub entries: Vec<Entry>,
+ pub turns: BTreeMap<String, Turn>,
+}
+
+/// Durable rows in sequence order, then live text that has no row yet.
+pub fn transcript<'a>(
+ rows: impl IntoIterator<Item = &'a Value>,
+ live: impl IntoIterator<Item = Entry>,
+) -> Transcript {
+ let mut out = Transcript::default();
+ let mut calls = BTreeMap::new();
+ for row in rows {
+ let id = row["id"].as_str().unwrap_or_default().to_owned();
+ let turn = row["turnId"].as_str().unwrap_or_default().to_owned();
+ let at = row["ts"].as_u64().unwrap_or_default();
+ match row["type"].as_str() {
+ Some("user") => {
+ let text = row["displayText"]
+ .as_str()
+ .filter(|text| !text.trim().is_empty())
+ .or_else(|| row["text"].as_str())
+ .unwrap_or_default()
+ .to_owned();
+ out.turns.entry(turn.clone()).or_default().started = Some(at);
+ out.entries.push(Entry::Prompt { id, turn, text, at });
+ }
+ Some("assistant") => {
+ if let Some(thought) = row["thinking"]["text"].as_str()
+ && !thought.trim().is_empty()
+ {
+ out.entries.push(Entry::Thought {
+ id: id.clone(),
+ turn: turn.clone(),
+ text: thought.to_owned(),
+ streaming: false,
+ });
+ }
+ if let Some(text) = row["text"].as_str()
+ && !text.is_empty()
+ {
+ out.entries.push(Entry::Text {
+ id,
+ turn,
+ text: text.to_owned(),
+ streaming: false,
+ });
+ }
+ }
+ Some("tool_call") => {
+ calls.insert((turn.clone(), id.clone()), out.entries.len());
+ out.entries.push(Entry::Tool {
+ id,
+ turn,
+ name: row["toolName"].as_str().unwrap_or("tool").to_owned(),
+ args: row["args"].clone(),
+ result: None,
+ });
+ }
+ Some("tool_result") => {
+ let call = row["toolUseId"].as_str().unwrap_or_default().to_owned();
+ if let Some(&ix) = calls.get(&(turn, call))
+ && let Entry::Tool { result, .. } = &mut out.entries[ix]
+ {
+ *result = Some(Outcome {
+ failed: row["isError"] == true,
+ output: output(&row["content"]),
+ });
+ }
+ }
+ Some("turn_state") => {
+ let ending = match row["status"].as_str() {
+ Some("completed") => Ending::Completed,
+ Some("failed") => Ending::Failed(
+ row["failureMessage"]
+ .as_str()
+ .unwrap_or("未知错误")
+ .to_owned(),
+ ),
+ Some("aborted") => Ending::Cancelled,
+ _ => continue,
+ };
+ out.turns.entry(turn).or_default().ended = Some((at, ending));
+ }
+ _ => {}
+ }
+ }
+ let known: HashSet<(String, &'static str)> = out
+ .entries
+ .iter()
+ .filter_map(|entry| match entry {
+ Entry::Text { id, .. } => Some((id.clone(), "text")),
+ Entry::Thought { id, .. } => Some((id.clone(), "thought")),
+ _ => None,
+ })
+ .collect();
+ for entry in live {
+ let kind = if matches!(entry, Entry::Thought { .. }) {
+ "thought"
+ } else {
+ "text"
+ };
+ if !known.contains(&(entry.id().to_owned(), kind)) {
+ out.entries.push(entry);
+ }
+ }
+ out
+}
+
+fn output(content: &Value) -> String {
+ match content["kind"].as_str() {
+ Some("text") => content["text"].as_str().unwrap_or_default().to_owned(),
+ Some("json") => serde_json::to_string_pretty(&content["value"]).unwrap_or_default(),
+ Some("image") => "[图片]".into(),
+ _ => content.to_string(),
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Row {
+ Prompt(usize),
+ Text(usize),
+ /// A run of tool calls and thoughts with no answer text between them.
+ Work {
+ items: Vec<usize>,
+ live: bool,
+ },
+ /// The folded work of a settled turn.
+ Summary {
+ turn: String,
+ open: bool,
+ },
+ Notice {
+ turn: String,
+ text: String,
+ },
+ Footer {
+ turn: String,
+ },
+ Working,
+}
+
+impl Row {
+ /// Stable across refolds, so measurements and state follow the row.
+ pub fn key(&self, entries: &[Entry]) -> String {
+ match self {
+ Self::Prompt(ix) => format!("prompt:{}", entries[*ix].id()),
+ Self::Text(ix) => format!("text:{}", entries[*ix].id()),
+ Self::Work { items, .. } => format!("work:{}", entries[items[0]].id()),
+ Self::Summary { turn, .. } => format!("summary:{turn}"),
+ Self::Notice { turn, .. } => format!("notice:{turn}"),
+ Self::Footer { turn } => format!("footer:{turn}"),
+ Self::Working => "working".into(),
+ }
+ }
+}
+
+/// `running` is the turn the Host reports live, if any; `open` holds the
+/// settled turns whose folded work the reader expanded.
+pub fn fold(transcript: &Transcript, running: Option<&str>, open: &HashSet<String>) -> Vec<Row> {
+ let entries = &transcript.entries;
+ // Unfolded rows, grouped by turn in order of appearance.
+ let mut turns: Vec<(String, Vec<Row>)> = Vec::new();
+ for (ix, entry) in entries.iter().enumerate() {
+ let turn = entry.turn();
+ if turns.last().is_none_or(|(id, _)| id != turn) {
+ turns.push((turn.to_owned(), Vec::new()));
+ }
+ let rows = &mut turns.last_mut().expect("pushed").1;
+ match entry {
+ Entry::Prompt { .. } => rows.push(Row::Prompt(ix)),
+ Entry::Text { .. } if entry.answers() => rows.push(Row::Text(ix)),
+ _ => match rows.last_mut() {
+ Some(Row::Work { items, .. }) => items.push(ix),
+ _ => rows.push(Row::Work {
+ items: vec![ix],
+ live: false,
+ }),
+ },
+ }
+ }
+ let mut out = Vec::new();
+ for (turn, rows) in turns {
+ let state = transcript.turns.get(&turn);
+ if running == Some(turn.as_str()) {
+ let last = rows.len().saturating_sub(1);
+ for (ix, mut row) in rows.into_iter().enumerate() {
+ if let Row::Work { live, .. } = &mut row {
+ *live = ix == last;
+ }
+ out.push(row);
+ }
+ continue;
+ }
+ let answer_from = rows
+ .iter()
+ .rposition(|row| !matches!(row, Row::Text(_)))
+ .map_or(0, |ix| ix + 1);
+ let hidden_from = rows
+ .iter()
+ .position(|row| !matches!(row, Row::Prompt(_)))
+ .unwrap_or(rows.len());
+ let expanded = open.contains(&turn);
+ let mut visible_answer = false;
+ for (ix, row) in rows.into_iter().enumerate() {
+ if ix == hidden_from && hidden_from < answer_from {
+ out.push(Row::Summary {
+ turn: turn.clone(),
+ open: expanded,
+ });
+ }
+ let hidden = ix >= hidden_from && ix < answer_from;
+ if hidden && !expanded {
+ continue;
+ }
+ visible_answer |= ix >= answer_from && matches!(row, Row::Text(_));
+ out.push(row);
+ }
+ if let Some((_, Ending::Failed(message))) = state.and_then(|state| state.ended.as_ref()) {
+ out.push(Row::Notice {
+ turn: turn.clone(),
+ text: format!("运行失败:{message}"),
+ });
+ }
+ if visible_answer {
+ out.push(Row::Footer { turn });
+ }
+ }
+ if running.is_some() {
+ out.push(Row::Working);
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn rows() -> Vec<Value> {
+ vec![
+ json!({"type":"user","id":"u1","turnId":"t1","ts":1000,"text":"fix it"}),
+ json!({"type":"assistant","id":"a1","turnId":"t1","text":"Looking.","thinking":{"text":"plan"}}),
+ json!({"type":"tool_call","id":"c1","turnId":"t1","toolName":"Shell","args":{"command":"ls"}}),
+ json!({"type":"tool_result","id":"r1","turnId":"t1","toolUseId":"c1","isError":false,"content":{"kind":"text","text":"a.rs"}}),
+ json!({"type":"assistant","id":"a2","turnId":"t1","text":"Done."}),
+ json!({"type":"turn_state","id":"s1","turnId":"t1","ts":66000,"status":"completed"}),
+ ]
+ }
+
+ fn kinds(rows: &[Row], entries: &[Entry]) -> Vec<String> {
+ rows.iter().map(|row| row.key(entries)).collect()
+ }
+
+ #[test]
+ fn a_settled_turn_folds_its_work_above_the_answer() {
+ let rows = rows();
+ let transcript = transcript(&rows, []);
+ let folded = fold(&transcript, None, &HashSet::new());
+ assert_eq!(
+ kinds(&folded, &transcript.entries),
+ ["prompt:u1", "summary:t1", "text:a2", "footer:t1"]
+ );
+ let Entry::Tool { result, .. } = &transcript.entries[3] else {
+ panic!("{:?}", transcript.entries[3]);
+ };
+ assert_eq!(result.as_ref().unwrap().output, "a.rs");
+ assert_eq!(transcript.turns["t1"].started, Some(1000));
+ }
+
+ #[test]
+ fn opening_the_fold_shows_the_work_in_order() {
+ let rows = rows();
+ let transcript = transcript(&rows, []);
+ let open = HashSet::from(["t1".to_owned()]);
+ let folded = fold(&transcript, None, &open);
+ assert_eq!(
+ kinds(&folded, &transcript.entries),
+ [
+ "prompt:u1",
+ "summary:t1",
+ "work:a1",
+ "text:a1",
+ "work:c1",
+ "text:a2",
+ "footer:t1",
+ ]
+ );
+ }
+
+ #[test]
+ fn a_running_turn_shows_everything_and_only_its_last_group_is_live() {
+ let rows = &rows()[..4];
+ let transcript = transcript(rows, []);
+ let folded = fold(&transcript, Some("t1"), &HashSet::new());
+ assert_eq!(
+ kinds(&folded, &transcript.entries),
+ ["prompt:u1", "work:a1", "text:a1", "work:c1", "working"]
+ );
+ assert!(matches!(folded[1], Row::Work { live: false, .. }));
+ assert!(matches!(folded[3], Row::Work { live: true, .. }));
+ }
+
+ #[test]
+ fn live_text_joins_until_its_row_lands() {
+ let rows = &rows()[..1];
+ let live = Entry::Text {
+ id: "a1".into(),
+ turn: "t1".into(),
+ text: "Look".into(),
+ streaming: true,
+ };
+ let partial = transcript(rows, [live.clone()]);
+ assert_eq!(partial.entries.len(), 2);
+ let all = rows_with_answer();
+ let settled = transcript(&all, [live]);
+ assert_eq!(
+ settled
+ .entries
+ .iter()
+ .filter(|entry| entry.id() == "a1")
+ .count(),
+ 1
+ );
+ }
+
+ fn rows_with_answer() -> Vec<Value> {
+ let mut rows = rows()[..1].to_vec();
+ rows.push(json!({"type":"assistant","id":"a1","turnId":"t1","text":"Looking."}));
+ rows
+ }
+
+ #[test]
+ fn a_failed_turn_says_so_and_a_tool_only_turn_has_no_footer() {
+ let rows = vec![
+ json!({"type":"user","id":"u1","turnId":"t1","ts":0,"text":"go"}),
+ json!({"type":"tool_call","id":"c1","turnId":"t1","toolName":"Shell","args":{}}),
+ json!({"type":"turn_state","id":"s","turnId":"t1","ts":5,"status":"failed","failureMessage":"quota"}),
+ ];
+ let transcript = transcript(&rows, []);
+ let folded = fold(&transcript, None, &HashSet::new());
+ assert_eq!(
+ kinds(&folded, &transcript.entries),
+ ["prompt:u1", "summary:t1", "notice:t1"]
+ );
+ }
+}
diff --git a/crates/desktop/src/chat/tools.rs b/crates/desktop/src/chat/tools.rs
new file mode 100644
index 0000000000..b576f04e03
--- /dev/null
+++ b/crates/desktop/src/chat/tools.rs
@@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! How a tool call reads in one line: an icon, a verb and what it acted on.
+
+use super::rows::Entry;
+use serde_json::Value;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+ Think,
+ Run,
+ Edit,
+ Read,
+ Search,
+ Plan,
+ Ask,
+ Tool,
+}
+
+impl Kind {
+ pub fn of(entry: &Entry) -> Self {
+ let Entry::Tool { name, .. } = entry else {
+ return Self::Think;
+ };
+ match name.as_str() {
+ "Shell" | "Bash" | "exec" | "shell" => Self::Run,
+ "apply_patch" | "Edit" | "Write" | "MultiEdit" => Self::Edit,
+ "Read" | "WebFetch" => Self::Read,
+ "Glob" | "Grep" | "WebSearch" | "web_search" | "tool_search" => Self::Search,
+ "TodoWrite" | "update_plan" => Self::Plan,
+ "AskUserQuestion" => Self::Ask,
+ _ => Self::Tool,
+ }
+ }
+
+ pub fn icon(self) -> &'static str {
+ match self {
+ Self::Think => "icons/sparkles.svg",
+ Self::Run => "icons/square-terminal.svg",
+ Self::Edit => "icons/pencil.svg",
+ Self::Read => "icons/file-text.svg",
+ Self::Search => "icons/search.svg",
+ Self::Plan => "icons/list.svg",
+ Self::Ask => "icons/message-circle-question-mark.svg",
+ Self::Tool => "icons/wrench.svg",
+ }
+ }
+
+ pub fn verb(self) -> &'static str {
+ match self {
+ Self::Think => "思考",
+ Self::Run => "运行",
+ Self::Edit => "编辑",
+ Self::Read => "读取",
+ Self::Search => "搜索",
+ Self::Plan => "计划",
+ Self::Ask => "提问",
+ Self::Tool => "工具",
+ }
+ }
+
+ fn counted(self, count: usize) -> String {
+ match self {
+ Self::Think => format!("{count} 段思考"),
+ Self::Run => format!("{count} 条命令"),
+ Self::Edit => format!("{count} 处编辑"),
+ Self::Read => format!("{count} 次读取"),
+ Self::Search => format!("{count} 次搜索"),
+ Self::Plan => format!("{count} 次计划"),
+ Self::Ask => format!("{count} 个问题"),
+ Self::Tool => format!("{count} 次工具调用"),
+ }
+ }
+}
+
+/// What the call acted on, in a few words.
+pub fn target(entry: &Entry) -> String {
+ let Entry::Tool { name, args, .. } = entry else {
+ return String::new();
+ };
+ let field = |keys: &[&str]| {
+ keys.iter()
+ .find_map(|key| args[*key].as_str())
+ .map(str::to_owned)
+ };
+ let text = match Kind::of(entry) {
+ Kind::Run => field(&["description", "command", "cmd"]),
+ Kind::Edit | Kind::Read => field(&["path", "file_path", "url"])
+ .or_else(|| args["operation"]["path"].as_str().map(str::to_owned))
+ .map(|path| file_name(&path)),
+ Kind::Search => field(&["pattern", "query"]),
+ Kind::Tool => Some(humanize(name)),
+ _ => None,
+ };
+ first_line(&text.unwrap_or_default())
+}
+
+/// Summary of a run of calls: counts per kind in the order they appear.
+pub fn summary(entries: &[&Entry], running: bool) -> String {
+ let mut counts: Vec<(Kind, usize)> = Vec::new();
+ for entry in entries {
+ let kind = Kind::of(entry);
+ match counts.iter_mut().find(|(seen, _)| *seen == kind) {
+ Some((_, count)) => *count += 1,
+ None => counts.push((kind, 1)),
+ }
+ }
+ let parts: Vec<String> = counts
+ .into_iter()
+ .map(|(kind, count)| kind.counted(count))
+ .collect();
+ format!(
+ "{}{}",
+ if running {
+ "正在执行 "
+ } else {
+ "已执行 "
+ },
+ parts.join(" · ")
+ )
+}
+
+/// The arguments worth showing when a call is expanded.
+pub fn input(entry: &Entry) -> Option<String> {
+ let Entry::Tool { args, .. } = entry else {
+ return None;
+ };
+ if let Some(command) = args["command"].as_str() {
+ return Some(command.to_owned());
+ }
+ match args {
+ Value::Object(map) if map.is_empty() => None,
+ Value::Null => None,
+ _ => serde_json::to_string_pretty(args).ok(),
+ }
+}
+
+fn file_name(path: &str) -> String {
+ path.rsplit(['/', '\\'])
+ .find(|part| !part.is_empty())
+ .unwrap_or(path)
+ .to_owned()
+}
+
+fn first_line(text: &str) -> String {
+ let line = text
+ .lines()
+ .find(|line| !line.trim().is_empty())
+ .unwrap_or("");
+ line.trim().to_owned()
+}
+
+/// `mcp__github__list_issues` reads as "List issues".
+fn humanize(name: &str) -> String {
+ if name.contains(' ') {
+ return name.to_owned();
+ }
+ let leaf = name.rsplit([':', '.', '/']).next().unwrap_or(name);
+ let leaf = leaf.rsplit("__").next().unwrap_or(leaf);
+ let mut words = String::new();
+ let mut previous_lower = false;
+ for c in leaf.chars() {
+ if c == '_' || c == '-' {
+ words.push(' ');
+ previous_lower = false;
+ continue;
+ }
+ if c.is_uppercase() && previous_lower {
+ words.push(' ');
+ }
+ previous_lower = c.is_lowercase();
+ words.extend(c.to_lowercase());
+ }
+ let mut chars = words.trim().chars();
+ match chars.next() {
+ Some(first) => first.to_uppercase().chain(chars).collect(),
+ None => name.to_owned(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn tool(name: &str, args: Value) -> Entry {
+ Entry::Tool {
+ id: "c".into(),
+ turn: "t".into(),
+ name: name.into(),
+ args,
+ result: None,
+ }
+ }
+
+ #[test]
+ fn a_call_reads_as_verb_and_target() {
+ assert_eq!(
+ target(&tool("Shell", json!({"command": "cargo test\n--all"}))),
+ "cargo test"
+ );
+ assert_eq!(
+ target(&tool("Read", json!({"path": "src/main.rs"}))),
+ "main.rs"
+ );
+ assert_eq!(
+ target(&tool("mcp__github__list_issues", json!({}))),
+ "List issues"
+ );
+ assert_eq!(target(&tool("fetchPage", json!({}))), "Fetch page");
+ assert_eq!(Kind::of(&tool("Grep", json!({}))).verb(), "搜索");
+ }
+
+ #[test]
+ fn a_group_summarizes_by_kind_in_order() {
+ let calls = [
+ tool("Shell", json!({})),
+ tool("Read", json!({})),
+ tool("Shell", json!({})),
+ ];
+ let refs: Vec<&Entry> = calls.iter().collect();
+ assert_eq!(summary(&refs, false), "已执行 2 条命令 · 1 次读取");
+ }
+}
diff --git a/crates/desktop/src/chat/view.rs b/crates/desktop/src/chat/view.rs
new file mode 100644
index 0000000000..f6904feef2
--- /dev/null
+++ b/crates/desktop/src/chat/view.rs
@@ -0,0 +1,834 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::{
+ Chat,
+ rows::{Ending, Entry, Row},
+ tools::{self, Kind},
+};
+use crate::{
+ md::view::{self as md, Metrics, Scope},
+ theme::{Theme, theme},
+ ui::{self, icon, icon_button, motion, select::Key},
+};
+use chrono::{Local, TimeZone};
+use gpui_kit::{
+ AnyElement, App, Context, Div, Entity, FontWeight, InteractiveElement, IntoElement,
+ MouseButton, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
+ StyledText, Window, div, list, prelude::FluentBuilder, px,
+};
+use std::time::{Duration, Instant};
+
+/// The reading column, shared by the transcript and the composer.
+pub(super) const COLUMN: f32 = 720.;
+
+impl Chat {
+ fn scope<'a>(
+ &'a self,
+ row: &'a SharedString,
+ metrics: Metrics,
+ color: gpui_kit::Hsla,
+ ) -> Scope<'a> {
+ Scope {
+ row,
+ metrics,
+ color,
+ selection: &self.selection,
+ copied: &self.copied,
+ now: self.now,
+ }
+ }
+
+ fn render_row(&self, ix: usize, this: &Entity<Chat>, cx: &App) -> AnyElement {
+ let Some(row) = self.fold.get(ix) else {
+ return div().into_any_element();
+ };
+ let theme = theme(cx);
+ let entries = &self.transcript.entries;
+ let key: SharedString = self.keys[ix].clone().into();
+ let (content, turn): (AnyElement, Option<&str>) = match row {
+ Row::Prompt(entry) => (self.prompt(*entry, &key, theme, cx), None),
+ Row::Text(entry) => (self.reply(*entry, theme, cx), Some(entries[*entry].turn())),
+ Row::Work { items, live } => (
+ self.work(ix, &key, items, *live, this, theme, cx),
+ Some(entries[items[0]].turn()),
+ ),
+ Row::Summary { turn, open } => (self.summary(turn, *open, this, theme), Some(turn)),
+ Row::Notice { turn, text } => (
+ div()
+ .flex()
+ .gap(px(8.))
+ .items_start()
+ .text_size(px(13.))
+ .line_height(px(18.))
+ .text_color(theme.danger)
+ .child(
+ icon("icons/triangle-alert.svg", theme.danger)
+ .size(px(13.))
+ .mt(px(2.)),
+ )
+ .child(text.clone())
+ .into_any_element(),
+ Some(turn),
+ ),
+ Row::Footer { turn } => (self.footer(turn, this, theme, cx), Some(turn)),
+ Row::Working => (self.working(theme), None),
+ };
+ let top = match row {
+ _ if ix == 0 => 22.,
+ Row::Prompt(_) => 48.,
+ Row::Footer { .. } => 0.,
+ _ => 8.,
+ };
+ let shell = div()
+ .id(key)
+ .w_full()
+ .flex()
+ .justify_center()
+ .px(px(20.))
+ .pt(px(top))
+ .pb(px(8.))
+ .child(div().w_full().max_w(px(COLUMN)).min_w_0().child(content));
+ match turn {
+ Some(turn) => {
+ let turn = turn.to_owned();
+ let chat = this.downgrade();
+ shell
+ .on_hover(move |hovered, _, cx| {
+ let _ = chat.update(cx, |chat, cx| {
+ let next = hovered.then(|| turn.clone());
+ if (*hovered || chat.hovered_turn.as_deref() == Some(turn.as_str()))
+ && chat.hovered_turn != next
+ {
+ chat.hovered_turn = next;
+ cx.notify();
+ }
+ });
+ })
+ .into_any_element()
+ }
+ None => shell.into_any_element(),
+ }
+ }
+
+ fn prompt(&self, entry: usize, key: &SharedString, theme: &Theme, cx: &App) -> AnyElement {
+ let body = self
+ .bodies
+ .get(&format!("prompt:{}", self.transcript.entries[entry].id()));
+ div()
+ .flex()
+ .justify_end()
+ .child(
+ div()
+ .max_w(px(540.))
+ .min_w_0()
+ .rounded(px(12.))
+ .bg(theme.text.opacity(0.055))
+ .px(px(11.))
+ .py(px(7.))
+ .when_some(body, |this, body| {
+ this.child(md::render(
+ body,
+ &self.scope(key, md::PROMPT, theme.text),
+ cx,
+ ))
+ }),
+ )
+ .into_any_element()
+ }
+
+ fn reply(&self, entry: usize, theme: &Theme, cx: &App) -> AnyElement {
+ let id = self.transcript.entries[entry].id();
+ let key: SharedString = format!("text:{id}").into();
+ match self.bodies.get(key.as_ref()) {
+ Some(body) => div()
+ .py(px(4.))
+ .child(md::render(
+ body,
+ &self.scope(&key, md::BODY, theme.text),
+ cx,
+ ))
+ .into_any_element(),
+ None => div().into_any_element(),
+ }
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn work(
+ &self,
+ row: usize,
+ key: &SharedString,
+ items: &[usize],
+ live: bool,
+ this: &Entity<Chat>,
+ theme: &Theme,
+ cx: &App,
+ ) -> AnyElement {
+ let entries = &self.transcript.entries;
+ let open = self.toggled.get(key.as_ref()).copied().unwrap_or(live);
+ let calls: Vec<&Entry> = items.iter().map(|ix| &entries[*ix]).collect();
+ let title = if live {
+ calls
+ .last()
+ .map(|entry| live_title(entry))
+ .unwrap_or_default()
+ } else {
+ tools::summary(&calls, false)
+ };
+ let chat = this.downgrade();
+ let toggle_key = key.to_string();
+ let group = SharedString::from(format!("{key}:header"));
+ let header = ui::pressable(
+ div()
+ .id(group.clone())
+ .group(group.clone())
+ .h(px(26.))
+ .flex()
+ .items_center()
+ .gap(px(6.))
+ .rounded(px(6.))
+ .text_size(px(12.5))
+ .line_height(px(16.))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(theme.muted)
+ .hover(|style| style.text_color(theme.text))
+ .focus_visible(|style| style.text_color(theme.text))
+ .child(div().min_w_0().truncate().child(title))
+ .child(
+ icon(
+ if open {
+ "icons/chevron-down.svg"
+ } else {
+ "icons/chevron-right.svg"
+ },
+ theme.muted,
+ )
+ .size(px(10.))
+ .group_hover(group, |style| style.text_color(theme.text)),
+ ),
+ move |_, cx| {
+ let key = toggle_key.clone();
+ let _ = chat.update(cx, |chat, cx| chat.toggle(key, !open, row, cx));
+ },
+ );
+ div()
+ .flex()
+ .flex_col()
+ .gap(px(4.))
+ .child(header)
+ .when(open, |this_div| {
+ this_div.child(
+ div()
+ .ml(px(6.))
+ .pl(px(12.))
+ .pb(px(2.))
+ .border_l_1()
+ .border_color(theme.border)
+ .flex()
+ .flex_col()
+ .gap(px(8.))
+ .children(items.iter().enumerate().map(|(position, item)| {
+ let last = position + 1 == items.len();
+ self.item(row, *item, live && last, this, theme, cx)
+ })),
+ )
+ })
+ .into_any_element()
+ }
+
+ fn item(
+ &self,
+ row: usize,
+ entry_ix: usize,
+ live: bool,
+ this: &Entity<Chat>,
+ theme: &Theme,
+ cx: &App,
+ ) -> AnyElement {
+ let entry = &self.transcript.entries[entry_ix];
+ let kind = Kind::of(entry);
+ let item_key = format!("item:{}:{}", entry.id(), kind.verb());
+ let running = live
+ && match entry {
+ Entry::Tool { result, .. } => result.is_none(),
+ Entry::Thought { streaming, .. } => *streaming,
+ _ => false,
+ };
+ let failed = matches!(entry, Entry::Tool { result: Some(result), .. } if result.failed);
+ let detail = match entry {
+ Entry::Tool { result, .. } => tools::input(entry).is_some() || result.is_some(),
+ _ => true,
+ };
+ let default_open = running && matches!(entry, Entry::Thought { .. });
+ let open = detail && self.toggled.get(&item_key).copied().unwrap_or(default_open);
+ let target = match entry {
+ Entry::Thought { .. } if running => "思考中".to_owned(),
+ Entry::Thought { .. } => String::new(),
+ _ => tools::target(entry),
+ };
+ let pulse = self
+ .elapsed
+ .map(|elapsed| motion::cycle(elapsed, Duration::from_millis(1600)))
+ .map_or(1., |phase| {
+ 0.65 + 0.35 * (phase * std::f32::consts::TAU).cos()
+ });
+ let mark = if failed {
+ icon("icons/close.svg", theme.danger)
+ .size(px(10.))
+ .into_any_element()
+ } else if running {
+ div()
+ .size(px(5.))
+ .rounded_full()
+ .bg(theme.accent.opacity(pulse))
+ .into_any_element()
+ } else if detail {
+ icon(
+ if open {
+ "icons/chevron-down.svg"
+ } else {
+ "icons/chevron-right.svg"
+ },
+ theme.muted,
+ )
+ .size(px(10.))
+ .into_any_element()
+ } else {
+ div().into_any_element()
+ };
+ let header = div()
+ .id(SharedString::from(format!("{item_key}:header")))
+ .h(px(28.))
+ .px(px(8.))
+ .flex()
+ .items_center()
+ .gap(px(8.))
+ .text_size(px(12.5))
+ .line_height(px(16.))
+ .text_color(theme.muted)
+ .child(icon(kind.icon(), theme.muted).size(px(12.)))
+ .child(
+ div()
+ .flex_none()
+ .font_weight(FontWeight::SEMIBOLD)
+ .child(kind.verb()),
+ )
+ .child(div().flex_1().min_w_0().truncate().child(target))
+ .child(mark);
+ let header = if detail {
+ let chat = this.downgrade();
+ let key = item_key.clone();
+ ui::pressable(
+ header
+ .hover(|style| style.bg(theme.hover))
+ .focus_visible(|style| style.bg(theme.hover)),
+ move |_, cx| {
+ let key = key.clone();
+ let _ = chat.update(cx, |chat, cx| chat.toggle(key, !open, row, cx));
+ },
+ )
+ } else {
+ header
+ };
+ div()
+ .flex()
+ .flex_col()
+ .rounded(px(9.))
+ .border_1()
+ .border_color(theme.border)
+ .bg(theme.text.opacity(0.025))
+ .overflow_hidden()
+ .child(header)
+ .when(open, |card| {
+ card.child(self.item_detail(entry, &item_key, theme, cx))
+ })
+ .into_any_element()
+ }
+
+ fn item_detail(&self, entry: &Entry, item_key: &str, theme: &Theme, cx: &App) -> AnyElement {
+ let body = div()
+ .id(SharedString::from(format!("{item_key}:detail")))
+ .max_h(px(400.))
+ .overflow_y_scroll()
+ .border_t_1()
+ .border_color(theme.border)
+ .px(px(12.))
+ .py(px(8.))
+ .flex()
+ .flex_col()
+ .gap(px(8.));
+ let row: SharedString = item_key.to_owned().into();
+ match entry {
+ Entry::Thought { id, .. } => match self.bodies.get(&format!("thought:{id}")) {
+ Some(markdown) => body
+ .child(md::render(
+ markdown,
+ &self.scope(&row, md::COMPACT, theme.muted),
+ cx,
+ ))
+ .into_any_element(),
+ None => body.into_any_element(),
+ },
+ Entry::Tool { result, .. } => {
+ let mut sections = Vec::new();
+ if let Some(input) = tools::input(entry) {
+ let label = if Kind::of(entry) == Kind::Run {
+ "命令"
+ } else {
+ "参数"
+ };
+ sections.push((label, input));
+ }
+ if let Some(result) = result
+ && !result.output.is_empty()
+ {
+ sections.push(("输出", result.output.clone()));
+ }
+ body.children(
+ sections
+ .into_iter()
+ .enumerate()
+ .map(|(ordinal, (label, text))| {
+ let content: SharedString = text.into();
+ let styled = StyledText::new(content.clone());
+ let layout = styled.layout().clone();
+ div()
+ .flex()
+ .flex_col()
+ .gap(px(4.))
+ .child(
+ div()
+ .text_size(px(12.))
+ .line_height(px(16.))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(theme.muted)
+ .child(label),
+ )
+ .child(
+ div()
+ .font_family(theme.mono_font.clone())
+ .text_size(px(12.5))
+ .line_height(px(17.))
+ .text_color(theme.text)
+ .child(self.selection.text(
+ Key {
+ row: row.clone(),
+ ordinal: ordinal as u32,
+ },
+ content,
+ true,
+ layout,
+ styled,
+ )),
+ )
+ }),
+ )
+ .into_any_element()
+ }
+ _ => body.into_any_element(),
+ }
+ }
+
+ fn summary(&self, turn: &str, open: bool, this: &Entity<Chat>, theme: &Theme) -> AnyElement {
+ let state = self.transcript.turns.get(turn);
+ let seconds = state
+ .and_then(|state| Some((state.ended.as_ref()?.0).saturating_sub(state.started?)))
+ .map(|ms| (ms / 1000).max(1));
+ let label = match (state.and_then(|state| state.ended.as_ref()), seconds) {
+ (Some((_, Ending::Cancelled)), Some(seconds)) => {
+ format!("已停止,用时 {}", prose(seconds))
+ }
+ (_, Some(seconds)) => format!("工作了 {}", prose(seconds)),
+ _ => "工作过程".to_owned(),
+ };
+ let chat = this.downgrade();
+ let turn = turn.to_owned();
+ let line = || div().flex_1().h(px(1.)).bg(theme.border);
+ let group = SharedString::from(format!("summary:{turn}:toggle"));
+ ui::pressable(
+ div()
+ .id(group.clone())
+ .group(group.clone())
+ .h(px(24.))
+ .flex()
+ .items_center()
+ .gap(px(10.))
+ .text_color(theme.muted)
+ .hover(|style| style.text_color(theme.text))
+ .focus_visible(|style| style.text_color(theme.text))
+ .child(line())
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap(px(5.))
+ .px(px(2.))
+ .text_size(px(13.5))
+ .line_height(px(18.))
+ .font_weight(FontWeight::MEDIUM)
+ .child(label)
+ .child(
+ icon(
+ if open {
+ "icons/chevron-down.svg"
+ } else {
+ "icons/chevron-right.svg"
+ },
+ theme.muted,
+ )
+ .size(px(11.5))
+ .group_hover(group, |style| style.text_color(theme.text)),
+ ),
+ )
+ .child(line()),
+ move |_, cx| {
+ let turn = turn.clone();
+ let _ = chat.update(cx, |chat, cx| chat.toggle_turn(turn, cx));
+ },
+ )
+ .into_any_element()
+ }
+
+ fn footer(&self, turn: &str, this: &Entity<Chat>, theme: &Theme, cx: &App) -> AnyElement {
+ let entries = &self.transcript.entries;
+ let visible = self.hovered_turn.as_deref() == Some(turn);
+ // The answer is the trailing text of the turn; work before it is not copied.
+ let mut answer: Vec<&str> = Vec::new();
+ for entry in entries.iter().filter(|entry| entry.turn() == turn) {
+ match entry {
+ Entry::Text { text, .. } if !text.trim().is_empty() => answer.push(text),
+ Entry::Prompt { .. } => {}
+ _ => answer.clear(),
+ }
+ }
+ let text = answer.join("\n\n");
+ let copy_key: SharedString = format!("footer:{turn}").into();
+ let copied = self.copied.read(cx).is(©_key);
+ let source = self.copied.clone();
+ let time = self
+ .transcript
+ .turns
+ .get(turn)
+ .and_then(|state| state.ended.as_ref().map(|(at, _)| *at).or(state.started))
+ .map(clock);
+ let _ = this;
+ div()
+ .ml(px(-7.))
+ .h(px(27.))
+ .flex()
+ .items_center()
+ .gap(px(1.))
+ .when(!visible, |this| this.invisible())
+ .child(
+ icon_button(
+ SharedString::from(format!("{copy_key}:copy")),
+ if copied {
+ "icons/check.svg"
+ } else {
+ "icons/copy.svg"
+ },
+ false,
+ move |_, cx| {
+ let text = text.clone();
+ let key = copy_key.clone();
+ source.update(cx, |copied, cx| copied.copy(key, text, cx));
+ },
+ cx,
+ )
+ .tooltip(ui::tooltip(if copied {
+ "已复制"
+ } else {
+ "复制回答"
+ })),
+ )
+ .when_some(time, |this, time| {
+ this.child(
+ div()
+ .px(px(4.))
+ .text_size(px(12.5))
+ .line_height(px(14.))
+ .text_color(theme.muted)
+ .child(time),
+ )
+ })
+ .into_any_element()
+ }
+
+ fn working(&self, theme: &Theme) -> AnyElement {
+ let phase = self
+ .elapsed
+ .map(|elapsed| motion::cycle(elapsed, Duration::from_millis(1400)));
+ let started = self
+ .running()
+ .and_then(|turn| self.transcript.turns.get(&turn.turn_id))
+ .and_then(|turn| turn.started);
+ let label = match started {
+ Some(started) => {
+ let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
+ format!("正在工作 {}", compact(now.saturating_sub(started) / 1000))
+ }
+ None => "正在工作".to_owned(),
+ };
+ div()
+ .h(px(22.))
+ .flex()
+ .items_center()
+ .gap(px(8.))
+ .child(div().flex().gap(px(3.5)).children((0..3).map(|dot| {
+ let opacity = match phase {
+ Some(phase) => {
+ let wave = ((phase - dot as f32 * 0.18) * std::f32::consts::TAU).sin();
+ 0.25 + 0.75 * (wave + 1.) / 2.
+ }
+ None => 0.6,
+ };
+ div()
+ .size(px(4.5))
+ .rounded_full()
+ .bg(theme.muted.opacity(opacity))
+ })))
+ .child(
+ div()
+ .text_size(px(13.5))
+ .line_height(px(18.))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(theme.muted)
+ .child(label),
+ )
+ .into_any_element()
+ }
+
+ fn jump_button(&self, this: &Entity<Chat>, theme: &Theme) -> Div {
+ let chat = this.downgrade();
+ div()
+ .absolute()
+ .bottom(px(8.))
+ .left_0()
+ .right_0()
+ .flex()
+ .justify_center()
+ .child(
+ ui::pressable(
+ div()
+ .id("jump-to-latest")
+ .size(px(32.))
+ .rounded_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .border_1()
+ .border_color(theme.border)
+ .bg(theme.raised)
+ .shadow_sm()
+ .text_color(theme.text)
+ .hover(|style| style.bg(theme.overlay))
+ .focus_visible(|style| style.border_color(theme.accent))
+ .child(icon("icons/arrow-down.svg", theme.text).size(px(16.))),
+ move |_, cx| {
+ let _ = chat.update(cx, |chat, cx| chat.jump_to_latest(cx));
+ },
+ )
+ .tooltip(ui::tooltip("回到最新")),
+ )
+ }
+}
+
+fn live_title(entry: &Entry) -> String {
+ match entry {
+ Entry::Thought { .. } => "思考中".to_owned(),
+ _ => {
+ let target = tools::target(entry);
+ let verb = Kind::of(entry).verb();
+ if target.is_empty() {
+ format!("正在{verb}")
+ } else {
+ format!("正在{verb} {target}")
+ }
+ }
+ }
+}
+
+/// "1 分 5 秒"; seconds are dropped once it reaches hours.
+fn prose(seconds: u64) -> String {
+ let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
+ match (hours, minutes, seconds) {
+ (0, 0, s) => format!("{s} 秒"),
+ (0, m, 0) => format!("{m} 分钟"),
+ (0, m, s) => format!("{m} 分 {s} 秒"),
+ (h, 0, _) => format!("{h} 小时"),
+ (h, m, _) => format!("{h} 小时 {m} 分"),
+ }
+}
+
+/// "9s", "1m 5s", "1h 2m".
+pub fn compact(seconds: u64) -> String {
+ let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
+ match (hours, minutes, seconds) {
+ (0, 0, s) => format!("{s}s"),
+ (0, m, 0) => format!("{m}m"),
+ (0, m, s) => format!("{m}m {s}s"),
+ (h, 0, _) => format!("{h}h"),
+ (h, m, _) => format!("{h}h {m}m"),
+ }
+}
+
+/// "14:05" today, otherwise with the date.
+fn clock(ms: u64) -> String {
+ let Some(at) = Local.timestamp_millis_opt(ms as i64).single() else {
+ return String::new();
+ };
+ let today = Local::now().date_naive();
+ let date = at.date_naive();
+ if date == today {
+ at.format("%H:%M").to_string()
+ } else if today.pred_opt() == Some(date) {
+ at.format("昨天 %H:%M").to_string()
+ } else if at.format("%Y").to_string() == today.format("%Y").to_string() {
+ at.format("%-m月%-d日 %H:%M").to_string()
+ } else {
+ at.format("%Y年%-m月%-d日 %H:%M").to_string()
+ }
+}
+
+impl Render for Chat {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let now = Instant::now();
+ let fading = self.bodies.values().any(|body| body.fading(now));
+ let moving = self
+ .fold
+ .iter()
+ .any(|row| matches!(row, Row::Working | Row::Work { live: true, .. }));
+ self.elapsed = if fading || moving {
+ motion::now(window, cx)
+ } else {
+ None
+ };
+ self.now = self.elapsed.map(|_| now);
+ self.place(window);
+ if let Some(at_end) = self.list.is_scrolled_to_end() {
+ let scrollable = self.list.max_offset_for_scrollbar().y > px(0.5);
+ self.jump = scrollable
+ && !at_end
+ && !self.list.is_following_tail()
+ && !self.anchor.as_ref().is_some_and(|anchor| anchor.pinning);
+ }
+
+ let this = cx.entity();
+ let scrollbar = self
+ .scrollbar
+ .render(&self.list, this.entity_id(), window, cx);
+ let permission = self.render_permission(window, cx);
+ let composer = self.render_composer(cx).into_any_element();
+ let theme = theme(cx);
+ let rows = {
+ let this = this.downgrade();
+ list(self.list.clone(), move |ix, _, cx| {
+ this.upgrade()
+ .map(|chat| chat.read(cx).render_row(ix, &chat, cx))
+ .unwrap_or_else(|| div().into_any_element())
+ })
+ .size_full()
+ .pb(px(22.) + self.end_space)
+ };
+ div()
+ .size_full()
+ .flex()
+ .flex_col()
+ .bg(theme.base)
+ .text_color(theme.text)
+ .child(
+ div()
+ .id("transcript")
+ .relative()
+ .flex_1()
+ .min_h_0()
+ .key_context("Transcript")
+ .track_focus(&self.focus)
+ .on_action(cx.listener(Self::copy_selection))
+ .on_mouse_down(
+ MouseButton::Left,
+ cx.listener(|this, event: &gpui_kit::MouseDownEvent, window, cx| {
+ window.focus(&this.focus, cx);
+ if this.selection.mouse_down(event.position, event.click_count) {
+ cx.notify();
+ }
+ }),
+ )
+ .on_mouse_move(
+ cx.listener(|this, event: &gpui_kit::MouseMoveEvent, _, cx| {
+ if event.pressed_button == Some(MouseButton::Left)
+ && this.selection.mouse_move(event.position)
+ {
+ cx.notify();
+ }
+ }),
+ )
+ .on_mouse_up(
+ MouseButton::Left,
+ cx.listener(|this, _, _, _| this.selection.mouse_up()),
+ )
+ .on_mouse_up_out(
+ MouseButton::Left,
+ cx.listener(|this, _, _, _| this.selection.mouse_up()),
+ )
+ .child(self.selection.frame_start())
+ .child(rows)
+ .child(scrollbar)
+ .when(self.jump, |this_div| {
+ this_div.child(self.jump_button(&this, theme))
+ }),
+ )
+ .when_some(self.error.clone(), |this_div, error| {
+ this_div.child(
+ div()
+ .w_full()
+ .flex()
+ .justify_center()
+ .px(px(20.))
+ .pb(px(8.))
+ .child(
+ div()
+ .w_full()
+ .max_w(px(COLUMN))
+ .text_size(px(12.5))
+ .text_color(theme.danger)
+ .child(error),
+ ),
+ )
+ })
+ .children(permission)
+ .child(composer)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{compact, prose};
+
+ #[test]
+ fn durations_read_naturally() {
+ assert_eq!(prose(1), "1 秒");
+ assert_eq!(prose(65), "1 分 5 秒");
+ assert_eq!(prose(120), "2 分钟");
+ assert_eq!(prose(7380), "2 小时 3 分");
+ assert_eq!(compact(9), "9s");
+ assert_eq!(compact(65), "1m 5s");
+ assert_eq!(compact(3720), "1h 2m");
+ }
+}
diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs
index 2ac39468fe..380630d3a0 100644
--- a/crates/desktop/src/main.rs
+++ b/crates/desktop/src/main.rs
@@ -19,11 +19,19 @@
mod chat;
mod host;
+mod md;
+mod sidebar;
+mod theme;
+mod ui;
mod workspace;
-use gpui_kit::{component::Root, *};
+use gpui_kit::{
+ component::{Root, Theme as KitTheme, ThemeMode},
+ *,
+};
use maka_event_log::root::RootNamespaces;
use std::path::PathBuf;
+use theme::Theme;
fn default_root() -> Result<PathBuf, String> {
let namespaces = RootNamespaces::for_current_account().map_err(|error| error.to_string())?;
@@ -43,16 +51,49 @@ fn main() -> Result<(), String> {
};
let host = host::Host::new().map_err(|error| error.to_string())?;
gpui_kit::application()
- .with_assets(gpui_kit::assets::Assets)
+ .with_assets(ui::Assets)
.run(move |cx| {
gpui_kit::init(cx);
cx.set_global(host);
+ cx.bind_keys([
+ KeyBinding::new("cmd-c", chat::CopySelection, Some("Transcript")),
+ // Every Enter chord but plain Enter starts a new line.
+ KeyBinding::new(
+ "ctrl-enter",
+ component::input::Enter {
+ secondary: false,
+ shift: true,
+ },
+ Some("Input"),
+ ),
+ KeyBinding::new(
+ "alt-enter",
+ component::input::Enter {
+ secondary: false,
+ shift: true,
+ },
+ Some("Input"),
+ ),
+ ]);
let options = WindowOptions {
- window_bounds: Some(WindowBounds::centered(size(px(1080.), px(760.)), cx)),
+ window_bounds: Some(WindowBounds::centered(size(px(1280.), px(840.)), cx)),
+ window_min_size: Some(size(px(860.), px(560.))),
+ titlebar: Some(TitlebarOptions {
+ title: Some("Maka".into()),
+ appears_transparent: true,
+ traffic_light_position: Some(point(px(16.), px(17.))),
+ }),
..Default::default()
};
cx.open_window(options, |window, cx| {
- let view = cx.new(|cx| workspace::Workspace::new(root, cx));
+ apply_theme(window, cx);
+ window
+ .observe_window_appearance(|window, cx| {
+ apply_theme(window, cx);
+ window.refresh();
+ })
+ .detach();
+ let view = cx.new(|cx| workspace::Workspace::new(root, window, cx));
cx.new(|cx| Root::new(view, window, cx))
})
.expect("failed to open window");
@@ -60,3 +101,22 @@ fn main() -> Result<(), String> {
});
Ok(())
}
+
+/// Our palette, and gpui-kit's text field drawn in it.
+fn apply_theme(window: &mut Window, cx: &mut App) {
+ let theme = Theme::for_appearance(window.appearance());
+ let mode = if theme.dark {
+ ThemeMode::Dark
+ } else {
+ ThemeMode::Light
+ };
+ KitTheme::change(mode, Some(window), cx);
+ let kit = KitTheme::global_mut(cx);
+ kit.colors.foreground = theme.text;
+ kit.colors.muted_foreground = theme.muted;
+ kit.colors.caret = theme.accent;
+ kit.colors.selection = theme.selection;
+ kit.colors.background = theme.raised;
+ kit.font_family = theme.ui_font.clone();
+ cx.set_global(theme);
+}
diff --git a/crates/desktop/src/md/fade.rs b/crates/desktop/src/md/fade.rs
new file mode 100644
index 0000000000..e2b7088616
--- /dev/null
+++ b/crates/desktop/src/md/fade.rs
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Newly streamed text fades in. What fades is decided by when each byte of
+//! the source arrived, so restyling (`**bo` becoming bold "bo") never fades
+//! text again, and text already there when a view attaches never fades.
+//! Only colors change, never the laid-out text.
+
+use super::parse::Text;
+use std::{
+ ops::Range,
+ time::{Duration, Instant},
+};
+
+const MIN: f32 = 120.;
+const MAX: f32 = 400.;
+
+struct Chunk {
+ start: usize,
+ at: Instant,
+ duration: Duration,
+}
+
+pub struct Arrivals {
+ chunks: Vec<Chunk>,
+ /// Smoothed gap between updates: a slow stream fades slowly, a fast one
+ /// quickly, so the fade never trails far behind the text.
+ gap_ms: f32,
+ last: Option<Instant>,
+}
+
+impl Arrivals {
+ /// `source` was already on screen; it shows at full opacity.
+ pub fn seeded() -> Self {
+ Self {
+ chunks: Vec::new(),
+ gap_ms: 160.,
+ last: None,
+ }
+ }
+
+ pub fn record(&mut self, old: &str, new: &str, now: Instant) {
+ let mut prefix = old
+ .bytes()
+ .zip(new.bytes())
+ .take_while(|(a, b)| a == b)
+ .count();
+ while !new.is_char_boundary(prefix) {
+ prefix -= 1;
+ }
+ self.chunks.retain(|chunk| chunk.start < prefix);
+ if new.len() <= prefix {
+ return;
+ }
+ if let Some(last) = self.last {
+ let gap = (now - last).as_secs_f32() * 1000.;
+ self.gap_ms = 0.7 * self.gap_ms + 0.3 * gap.min(1000.);
+ }
+ self.last = Some(now);
+ self.chunks.push(Chunk {
+ start: prefix,
+ at: now,
+ duration: Duration::from_secs_f32((3. * self.gap_ms).clamp(MIN, MAX) / 1000.),
+ });
+ }
+
+ pub fn fading(&self, now: Instant) -> bool {
+ self.chunks
+ .iter()
+ .any(|chunk| now - chunk.at < chunk.duration)
+ }
+
+ /// Byte ranges of `text` that are still fading in, with their opacity.
+ pub fn spans(&self, text: &Text, now: Instant) -> Vec<(Range<usize>, f32)> {
+ let mut spans = Vec::new();
+ for (ix, chunk) in self.chunks.iter().enumerate() {
+ let progress = (now - chunk.at).as_secs_f32() / chunk.duration.as_secs_f32();
+ if progress >= 1. {
+ continue;
+ }
+ let end = self
+ .chunks
+ .get(ix + 1)
+ .map_or(usize::MAX, |next| next.start);
+ let range = text_offset(text, chunk.start)..text_offset(text, end);
+ if !range.is_empty() {
+ spans.push((range, 1. - (1. - progress.max(0.)).powf(1.6)));
+ }
+ }
+ spans
+ }
+}
+
+/// First byte of `text` whose source is at or after `source`.
+fn text_offset(text: &Text, source: usize) -> usize {
+ let pieces = &text.origins;
+ let ix = pieces.partition_point(|(_, start)| *start <= source);
+ let Some(ix) = ix.checked_sub(1) else {
+ return 0;
+ };
+ let (offset, start) = pieces[ix];
+ let piece_end = pieces.get(ix + 1).map_or(text.text.len(), |next| next.0);
+ let mut at = (offset + (source - start)).min(piece_end);
+ while !text.text.is_char_boundary(at) {
+ at -= 1;
+ }
+ at
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::md::parse::parse;
+
+ #[test]
+ fn only_the_new_source_fades() {
+ let start = Instant::now();
+ let mut arrivals = Arrivals::seeded();
+ arrivals.record("", "Hello ", start);
+ arrivals.record(
+ "Hello ",
+ "Hello **world**",
+ start + Duration::from_millis(120),
+ );
+ let text = &parse("Hello **world**", 0).blocks[0].texts[0];
+ let now = start + Duration::from_millis(200);
+ let spans = arrivals.spans(text, now);
+ assert_eq!(spans.len(), 2);
+ assert_eq!(spans[1].0, 6..11);
+ assert!(spans[0].1 > spans[1].1);
+ }
+
+ #[test]
+ fn text_already_shown_and_finished_chunks_are_opaque() {
+ let start = Instant::now();
+ let mut arrivals = Arrivals::seeded();
+ arrivals.record("", "Hello", start);
+ let text = &parse("Hello", 0).blocks[0].texts[0];
+ assert!(
+ arrivals
+ .spans(text, start + Duration::from_secs(1))
+ .is_empty()
+ );
+ assert!(!arrivals.fading(start + Duration::from_secs(1)));
+ assert!(Arrivals::seeded().spans(text, start).is_empty());
+ }
+
+ #[test]
+ fn a_rewrite_refades_only_past_the_common_prefix() {
+ let start = Instant::now();
+ let mut arrivals = Arrivals::seeded();
+ arrivals.record("", "abc def", start);
+ let later = start + Duration::from_secs(2);
+ arrivals.record("abc def", "abc xyz", later);
+ let text = &parse("abc xyz", 0).blocks[0].texts[0];
+ let spans = arrivals.spans(text, later);
+ assert_eq!(spans, vec![(4..7, 0.)]);
+ }
+}
diff --git a/crates/desktop/src/md/mend.rs b/crates/desktop/src/md/mend.rs
new file mode 100644
index 0000000000..57ab083c9d
--- /dev/null
+++ b/crates/desktop/src/md/mend.rs
@@ -0,0 +1,249 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Closes what a streaming reply has opened but not yet closed, in a copy
+//! used only for display, so `**bo` shows as bold "bo" rather than as `**bo`.
+//! Only the last paragraph is touched and only by trimming or appending at
+//! its end, so every earlier byte keeps its source offset. Code is never
+//! mended: an open fence already renders as code.
+
+use std::borrow::Cow;
+
+#[derive(Clone, Copy, PartialEq)]
+enum Open {
+ Strong,
+ Emphasis,
+ Strike,
+}
+
+impl Open {
+ fn marker(self) -> &'static str {
+ match self {
+ Self::Strong => "**",
+ Self::Emphasis => "*",
+ Self::Strike => "~~",
+ }
+ }
+}
+
+pub fn mend(source: &str) -> Cow<'_, str> {
+ if in_open_fence(source) {
+ return source.into();
+ }
+ let start = source.rfind("\n\n").map_or(0, |at| at + 2);
+ let paragraph = &source[start..];
+ let bytes = paragraph.as_bytes();
+ let mut stack: Vec<(Open, usize)> = Vec::new();
+ let mut code: Option<(usize, usize)> = None;
+ let mut url = false;
+ let mut partial = None;
+ let mut ix = 0;
+ while ix < bytes.len() {
+ let byte = bytes[ix];
+ if byte == b'\\' {
+ ix += 2;
+ continue;
+ }
+ let run = bytes[ix..].iter().take_while(|next| **next == byte).count();
+ if byte == b'`' {
+ code = match code {
+ Some((ticks, _)) if ticks == run => None,
+ Some(open) => Some(open),
+ None => Some((run, ix)),
+ };
+ ix += run;
+ continue;
+ }
+ if code.is_some() {
+ ix += run;
+ continue;
+ }
+ match byte {
+ b'*' | b'~' => {
+ let line_start = ix == 0 || bytes[ix - 1] == b'\n';
+ let next = bytes.get(ix + run).copied();
+ let before = ix.checked_sub(1).map(|at| bytes[at]);
+ // A list bullet, or a lone marker between spaces, is not emphasis.
+ let bullet = byte == b'*' && run == 1 && line_start && next == Some(b' ');
+ let can_open = next.is_some_and(|next| !next.is_ascii_whitespace());
+ let can_close = before.is_some_and(|before| !before.is_ascii_whitespace());
+ let wanted: &[Open] = match (byte, run) {
+ (b'*', 1) => &[Open::Emphasis],
+ (b'*', 2) => &[Open::Strong],
+ (b'*', 3) => &[Open::Strong, Open::Emphasis],
+ (b'~', 2) => &[Open::Strike],
+ _ => &[],
+ };
+ // Half of a `~~` still arriving.
+ if byte == b'~' && run == 1 && next.is_none() {
+ partial = Some(ix);
+ }
+ if !bullet {
+ for open in wanted {
+ match stack.iter().rposition(|(kind, _)| kind == open) {
+ Some(at) if can_close => {
+ stack.truncate(at);
+ }
+ _ if can_open || next.is_none() => stack.push((*open, ix)),
+ _ => {}
+ }
+ }
+ }
+ ix += run;
+ }
+ b']' if bytes.get(ix + 1) == Some(&b'(') => {
+ url = true;
+ ix += 2;
+ }
+ b')' if url => {
+ url = false;
+ ix += 1;
+ }
+ _ => ix += run,
+ }
+ }
+ if stack.is_empty() && code.is_none() && !url && partial.is_none() {
+ return source.into();
+ }
+ let mut mended = String::with_capacity(source.len() + 8);
+ // A marker with nothing after it yet is dropped rather than closed.
+ let cut = stack
+ .iter()
+ .chain(code.map(|(_, at)| (Open::Strong, at)).as_ref())
+ .filter(|(_, at)| {
+ paragraph[*at..]
+ .trim_start_matches(['*', '~', '`'])
+ .trim()
+ .is_empty()
+ })
+ .map(|(_, at)| *at)
+ .chain(partial)
+ .min()
+ .unwrap_or(paragraph.len());
+ mended.push_str(source[..start + cut].trim_end());
+ if let Some((ticks, at)) = code
+ && at < cut
+ {
+ mended.push_str(&"`".repeat(ticks));
+ }
+ if url && code.is_none() {
+ mended.push(')');
+ }
+ for (open, at) in stack.iter().rev() {
+ if *at < cut {
+ mended.push_str(open.marker());
+ }
+ }
+ mended.into()
+}
+
+fn in_open_fence(source: &str) -> bool {
+ let mut fence: Option<(u8, usize)> = None;
+ for line in source.lines() {
+ let trimmed = line.trim_start_matches(' ');
+ if line.len() - trimmed.len() > 3 {
+ continue;
+ }
+ let Some(&first) = trimmed.as_bytes().first() else {
+ continue;
+ };
+ if first != b'`' && first != b'~' {
+ continue;
+ }
+ let run = trimmed.bytes().take_while(|byte| *byte == first).count();
+ if run < 3 {
+ continue;
+ }
+ fence = match fence {
+ None => Some((first, run)),
+ Some((open, len))
+ if open == first && run >= len && trimmed[run..].trim().is_empty() =>
+ {
+ None
+ }
+ open => open,
+ };
+ }
+ fence.is_some()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::mend;
+ use crate::md::parse::parse;
+
+ fn rendered(source: &str) -> String {
+ parse(source, 0)
+ .blocks
+ .iter()
+ .flat_map(|block| block.texts.iter().map(|text| text.text.clone()))
+ .collect::<Vec<_>>()
+ .join("\n")
+ }
+
+ #[test]
+ fn closes_what_the_tail_left_open() {
+ assert_eq!(mend("Hello **bol"), "Hello **bol**");
+ assert_eq!(mend("a *b **c"), "a *b **c***");
+ assert_eq!(mend("run `cargo te"), "run `cargo te`");
+ assert_eq!(
+ mend("see [docs](https://x.y/pa"),
+ "see [docs](https://x.y/pa)"
+ );
+ assert_eq!(mend("gone ~~old"), "gone ~~old~~");
+ }
+
+ #[test]
+ fn drops_a_marker_with_nothing_after_it() {
+ assert_eq!(mend("Hello **"), "Hello");
+ assert_eq!(mend("Hello `"), "Hello");
+ assert_eq!(mend("Hello **bold** and *"), "Hello **bold** and");
+ }
+
+ #[test]
+ fn leaves_code_bullets_and_closed_text_alone() {
+ assert_eq!(mend("```rust\nlet a = **b"), "```rust\nlet a = **b");
+ assert_eq!(mend("* item"), "* item");
+ assert_eq!(mend("2 * 3 = 6"), "2 * 3 = 6");
+ assert_eq!(mend("done **now**."), "done **now**.");
+ assert_eq!(mend("`a*b`"), "`a*b`");
+ assert_eq!(mend("**old**\n\nnew"), "**old**\n\nnew");
+ }
+
+ #[test]
+ fn mending_twice_changes_nothing() {
+ for source in ["Hello **bol", "a *b **c", "run `x", "[a](b", "x ~~y"] {
+ let once = mend(source).into_owned();
+ assert_eq!(mend(&once), once, "{source}");
+ }
+ }
+
+ #[test]
+ fn no_prefix_of_a_reply_shows_a_marker() {
+ let reply = "Use **cargo test** to run `the suite`, then *read* the ~~old~~ [notes](https://n.example).";
+ for end in (1..=reply.len()).filter(|end| reply.is_char_boundary(*end)) {
+ let shown = rendered(&mend(&reply[..end]));
+ assert!(
+ !shown.contains(['*', '`', '~']) && !shown.contains("]("),
+ "{:?} shows {shown:?}",
+ &reply[..end]
+ );
+ }
+ }
+}
diff --git a/crates/desktop/src/md/mod.rs b/crates/desktop/src/md/mod.rs
new file mode 100644
index 0000000000..062009b8ba
--- /dev/null
+++ b/crates/desktop/src/md/mod.rs
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+mod fade;
+mod mend;
+pub mod parse;
+pub mod view;
diff --git a/crates/desktop/src/md/parse.rs b/crates/desktop/src/md/parse.rs
new file mode 100644
index 0000000000..47c53a663e
--- /dev/null
+++ b/crates/desktop/src/md/parse.rs
@@ -0,0 +1,573 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Markdown as a flat list of blocks, each holding the text it renders.
+//! Lists and quotes flatten into their paragraphs with a depth, so every
+//! block is one run of text (a table is one run per cell), which is what
+//! layout, the fade and selection all work on.
+
+use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
+use std::ops::Range;
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Kind {
+ Paragraph,
+ Heading(u8),
+ Item(Marker),
+ Code { language: Option<String> },
+ Rule,
+ Table { columns: usize },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Marker {
+ Bullet,
+ Number(u64),
+ Task(bool),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Style {
+ Strong,
+ Emphasis,
+ Strike,
+ Code,
+ Link,
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Text {
+ pub text: String,
+ /// Sorted by start; ranges may nest (a link inside strong text).
+ pub styles: Vec<(Range<usize>, Style)>,
+ pub links: Vec<(Range<usize>, String)>,
+ /// `(text offset, source offset)` where each source piece begins, so a
+ /// byte of text can be traced back to when its source arrived.
+ pub origins: Vec<(usize, usize)>,
+}
+
+#[cfg(test)]
+impl Text {
+ fn source_of(&self, offset: usize) -> usize {
+ let at = self.origins.partition_point(|(text, _)| *text <= offset);
+ match at.checked_sub(1) {
+ Some(ix) => self.origins[ix].1 + (offset - self.origins[ix].0),
+ None => self.origins.first().map_or(0, |(_, source)| *source),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Block {
+ pub kind: Kind,
+ /// List nesting; an item's continuation paragraphs share its depth.
+ pub depth: u8,
+ pub quote: u8,
+ /// One text for most blocks; a table holds its cells row by row, the
+ /// first row being the header.
+ pub texts: Vec<Text>,
+ /// Which top-level markdown block this came from.
+ top: usize,
+}
+
+pub struct Parsed {
+ pub blocks: Vec<Block>,
+ /// Source offset where each top-level block begins.
+ pub tops: Vec<usize>,
+ pub has_definitions: bool,
+}
+
+pub fn options() -> Options {
+ Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS
+}
+
+/// Parses `source`, reporting source offsets as if it began at `base`.
+pub fn parse(source: &str, base: usize) -> Parsed {
+ let mut builder = Builder {
+ base,
+ ..Builder::default()
+ };
+ let mut events = Parser::new_ext(source, options()).into_offset_iter();
+ for (event, range) in events.by_ref() {
+ builder.event(event, range);
+ }
+ let has_definitions = events.reference_definitions().iter().next().is_some();
+ builder.close();
+ Parsed {
+ blocks: builder.blocks,
+ tops: builder.tops,
+ has_definitions,
+ }
+}
+
+#[derive(Default)]
+struct Builder {
+ base: usize,
+ blocks: Vec<Block>,
+ tops: Vec<usize>,
+ nesting: usize,
+ open: Option<Block>,
+ styles: Vec<(usize, Style)>,
+ links: Vec<(usize, String)>,
+ lists: Vec<Option<u64>>,
+ marker: Option<Marker>,
+ quote: u8,
+ heading: Option<u8>,
+ in_code: bool,
+}
+
+impl Builder {
+ fn event(&mut self, event: Event, range: Range<usize>) {
+ let source = self.base + range.start;
+ match event {
+ Event::Start(tag) => {
+ if self.nesting == 0 {
+ self.tops.push(source);
+ }
+ self.nesting += 1;
+ self.start(tag, source);
+ }
+ Event::End(tag) => {
+ self.nesting -= 1;
+ self.end(tag);
+ }
+ Event::Text(text) => self.push(&text, source, None),
+ Event::Code(text) => {
+ let ticks = range.len().saturating_sub(text.len()) / 2;
+ self.push(&text, source + ticks, Some(Style::Code));
+ }
+ Event::Html(text) | Event::InlineHtml(text) => self.push(&text, source, None),
+ Event::SoftBreak => self.push(" ", source, None),
+ Event::HardBreak => self.push("\n", source, None),
+ Event::TaskListMarker(done) => self.marker = Some(Marker::Task(done)),
+ Event::Rule => {
+ if self.nesting == 0 {
+ self.tops.push(source);
+ }
+ self.close_text();
+ self.emit(Kind::Rule);
+ }
+ Event::FootnoteReference(label) => self.push(&format!("[{label}]"), source, None),
+ Event::InlineMath(text) | Event::DisplayMath(text) => {
+ self.push(&text, source, Some(Style::Code))
+ }
+ }
+ }
+
+ fn start(&mut self, tag: Tag, source: usize) {
+ match tag {
+ Tag::Paragraph | Tag::HtmlBlock => self.open_text(source),
+ Tag::Heading { level, .. } => {
+ self.close_text();
+ self.heading = Some(match level {
+ HeadingLevel::H1 => 1,
+ HeadingLevel::H2 => 2,
+ HeadingLevel::H3 => 3,
+ HeadingLevel::H4 => 4,
+ HeadingLevel::H5 => 5,
+ HeadingLevel::H6 => 6,
+ });
+ self.open_text(source);
+ }
+ Tag::BlockQuote(_) => {
+ self.close_text();
+ self.quote += 1;
+ }
+ Tag::CodeBlock(kind) => {
+ self.close_text();
+ let language = match kind {
+ CodeBlockKind::Fenced(info) => info
+ .split_whitespace()
+ .next()
+ .filter(|language| !language.is_empty())
+ .map(str::to_owned),
+ CodeBlockKind::Indented => None,
+ };
+ self.in_code = true;
+ self.open = Some(self.block(Kind::Code { language }, vec![Text::default()]));
+ }
+ Tag::List(start) => {
+ self.close_text();
+ self.lists.push(start);
+ }
+ Tag::Item => {
+ self.close_text();
+ let marker = match self.lists.last_mut() {
+ Some(Some(number)) => {
+ *number += 1;
+ Marker::Number(*number - 1)
+ }
+ _ => Marker::Bullet,
+ };
+ self.marker = Some(marker);
+ }
+ Tag::Table(alignments) => {
+ self.close_text();
+ self.open = Some(self.block(
+ Kind::Table {
+ columns: alignments.len(),
+ },
+ Vec::new(),
+ ));
+ }
+ Tag::TableCell => {
+ if let Some(table) = &mut self.open {
+ table.texts.push(Text::default());
+ }
+ }
+ Tag::Emphasis => self.style(Style::Emphasis),
+ Tag::Strong => self.style(Style::Strong),
+ Tag::Strikethrough => self.style(Style::Strike),
+ Tag::Link { dest_url, .. } => {
+ self.open_text(source);
+ self.style(Style::Link);
+ let at = self.len();
+ self.links.push((at, dest_url.into_string()));
+ }
+ Tag::Image { .. } => self.open_text(source),
+ _ => {}
+ }
+ }
+
+ fn end(&mut self, tag: TagEnd) {
+ match tag {
+ TagEnd::Paragraph | TagEnd::HtmlBlock => self.close_text(),
+ TagEnd::Heading(_) => {
+ self.close_text();
+ self.heading = None;
+ }
+ TagEnd::BlockQuote(_) => {
+ self.close_text();
+ self.quote = self.quote.saturating_sub(1);
+ }
+ TagEnd::CodeBlock => {
+ self.in_code = false;
+ if let Some(mut block) = self.open.take() {
+ if let Some(text) = block.texts.first_mut()
+ && text.text.ends_with('\n')
+ {
+ text.text.pop();
+ }
+ self.blocks.push(block);
+ }
+ }
+ TagEnd::List(_) => {
+ self.close_text();
+ self.lists.pop();
+ }
+ TagEnd::Item => {
+ self.close_text();
+ // An item with no text still shows its marker.
+ if let Some(marker) = self.marker.take() {
+ self.emit(Kind::Item(marker));
+ }
+ }
+ TagEnd::Table => {
+ if let Some(block) = self.open.take() {
+ self.blocks.push(block);
+ }
+ }
+ TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.unstyle(),
+ TagEnd::Link => {
+ self.unstyle();
+ if let Some((start, url)) = self.links.pop() {
+ let end = self.len();
+ if let Some(text) = self.current() {
+ text.links.push((start..end, url));
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn block(&self, kind: Kind, texts: Vec<Text>) -> Block {
+ Block {
+ kind,
+ depth: self.lists.len() as u8,
+ quote: self.quote,
+ texts,
+ top: self.tops.len().saturating_sub(1),
+ }
+ }
+
+ fn emit(&mut self, kind: Kind) {
+ let block = self.block(kind, vec![Text::default()]);
+ self.blocks.push(block);
+ }
+
+ fn open_text(&mut self, _source: usize) {
+ if self.open.is_some() {
+ return;
+ }
+ let kind = match (self.heading, self.marker.take()) {
+ (Some(level), _) => Kind::Heading(level),
+ (None, Some(marker)) => Kind::Item(marker),
+ (None, None) => Kind::Paragraph,
+ };
+ self.open = Some(self.block(kind, vec![Text::default()]));
+ }
+
+ fn close_text(&mut self) {
+ if self.in_code {
+ return;
+ }
+ if let Some(block) = &self.open
+ && matches!(block.kind, Kind::Table { .. })
+ {
+ return;
+ }
+ if let Some(block) = self.open.take() {
+ self.blocks.push(block);
+ }
+ self.styles.clear();
+ }
+
+ fn current(&mut self) -> Option<&mut Text> {
+ self.open.as_mut()?.texts.last_mut()
+ }
+
+ fn len(&mut self) -> usize {
+ self.current().map_or(0, |text| text.text.len())
+ }
+
+ fn style(&mut self, style: Style) {
+ let at = self.len();
+ self.styles.push((at, style));
+ }
+
+ fn unstyle(&mut self) {
+ if let Some((start, style)) = self.styles.pop() {
+ let end = self.len();
+ if let Some(text) = self.current()
+ && start < end
+ {
+ text.styles.push((start..end, style));
+ }
+ }
+ }
+
+ fn push(&mut self, piece: &str, source: usize, style: Option<Style>) {
+ if piece.is_empty() {
+ return;
+ }
+ if self.open.is_none() {
+ self.open_text(source);
+ }
+ let Some(text) = self.current() else {
+ return;
+ };
+ let start = text.text.len();
+ text.origins.push((start, source));
+ text.text.push_str(piece);
+ if let Some(style) = style {
+ text.styles.push((start..text.text.len(), style));
+ }
+ }
+
+ fn close(&mut self) {
+ self.in_code = false;
+ if let Some(block) = self.open.take() {
+ self.blocks.push(block);
+ }
+ for block in &mut self.blocks {
+ for text in &mut block.texts {
+ text.styles.sort_by_key(|(range, _)| range.start);
+ }
+ }
+ }
+}
+
+/// A markdown document that grows as a reply streams in. Blocks before the
+/// last two top-level blocks are kept; each update reparses only from there,
+/// because a new line can still reshape the block before it (a table's
+/// delimiter row, a setext underline).
+#[derive(Default)]
+pub struct Document {
+ source: String,
+ streaming: bool,
+ settled: Vec<Block>,
+ settled_end: usize,
+ tail: Vec<Block>,
+ /// A link definition can change any earlier link, so nothing settles.
+ whole: bool,
+}
+
+impl Document {
+ pub fn source(&self) -> &str {
+ &self.source
+ }
+
+ pub fn blocks(&self) -> impl Iterator<Item = &Block> {
+ self.settled.iter().chain(&self.tail)
+ }
+
+ /// Replaces the source. While `streaming`, the unfinished end is mended
+ /// so half-typed markers do not show.
+ pub fn update(&mut self, source: &str, streaming: bool) {
+ if self.source == source && self.streaming == streaming && !source.is_empty() {
+ return;
+ }
+ if !source.starts_with(&self.source[..self.settled_end]) {
+ self.restart();
+ }
+ self.source.clear();
+ self.source.push_str(source);
+ self.streaming = streaming;
+ let mut parsed = self.parse_tail();
+ if parsed.has_definitions && self.settled_end > 0 {
+ self.restart();
+ parsed = self.parse_tail();
+ }
+ self.whole |= parsed.has_definitions;
+ let mut blocks = parsed.blocks;
+ if !self.whole && parsed.tops.len() > 2 {
+ let keep = parsed.tops.len() - 2;
+ let split = blocks.partition_point(|block| block.top < keep);
+ self.settled.extend(blocks.drain(..split));
+ self.settled_end = parsed.tops[keep];
+ }
+ self.tail = blocks;
+ }
+
+ fn restart(&mut self) {
+ self.settled.clear();
+ self.settled_end = 0;
+ self.whole = false;
+ }
+
+ fn parse_tail(&self) -> Parsed {
+ let tail = &self.source[self.settled_end..];
+ if self.streaming {
+ parse(&super::mend::mend(tail), self.settled_end)
+ } else {
+ parse(tail, self.settled_end)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn texts(blocks: &[Block]) -> Vec<(Kind, Vec<String>)> {
+ blocks
+ .iter()
+ .map(|block| {
+ (
+ block.kind.clone(),
+ block.texts.iter().map(|text| text.text.clone()).collect(),
+ )
+ })
+ .collect()
+ }
+
+ const CORPUS: &str = "# Title\n\nIntro with **bold** and `code` and a [link](https://x.y).\n\n- one\n- two\n - nested\n\n1. first\n2. second\n\n> quoted\n> text\n\n```rust\nfn main() {}\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n\nSetext\n===\n\nDone ~~now~~.\n";
+
+ #[test]
+ fn flattens_lists_quotes_tables_and_code() {
+ let parsed = parse(CORPUS, 0);
+ let blocks = texts(&parsed.blocks);
+ assert_eq!(blocks[0], (Kind::Heading(1), vec!["Title".into()]));
+ assert_eq!(
+ blocks[1].1,
+ vec!["Intro with bold and code and a link.".to_string()]
+ );
+ assert_eq!(blocks[2], (Kind::Item(Marker::Bullet), vec!["one".into()]));
+ assert_eq!(parsed.blocks[4].depth, 2);
+ assert_eq!(
+ blocks[5],
+ (Kind::Item(Marker::Number(1)), vec!["first".into()])
+ );
+ assert_eq!(
+ blocks[6],
+ (Kind::Item(Marker::Number(2)), vec!["second".into()])
+ );
+ assert_eq!(parsed.blocks[7].quote, 1);
+ assert_eq!(blocks[7].1, vec!["quoted text".to_string()]);
+ assert_eq!(
+ blocks[8],
+ (
+ Kind::Code {
+ language: Some("rust".into())
+ },
+ vec!["fn main() {}".into()]
+ )
+ );
+ assert_eq!(
+ blocks[9],
+ (
+ Kind::Table { columns: 2 },
+ vec!["a".into(), "b".into(), "1".into(), "2".into()]
+ )
+ );
+ assert_eq!(blocks[10].0, Kind::Rule);
+ assert_eq!(blocks[11], (Kind::Heading(1), vec!["Setext".into()]));
+ }
+
+ #[test]
+ fn styles_and_origins_point_back_into_the_source() {
+ let source = "Intro with **bold** and `code`.";
+ let parsed = parse(source, 0);
+ let text = &parsed.blocks[0].texts[0];
+ let bold = text.text.find("bold").unwrap();
+ assert!(text.styles.contains(&(bold..bold + 4, Style::Strong)));
+ assert_eq!(&source[text.source_of(bold)..][..4], "bold");
+ let code = text.text.find("code").unwrap();
+ assert_eq!(&source[text.source_of(code)..][..4], "code");
+ }
+
+ #[test]
+ fn streaming_every_prefix_ends_like_a_full_parse() {
+ let mut document = Document::default();
+ for end in (1..=CORPUS.len()).filter(|end| CORPUS.is_char_boundary(*end)) {
+ document.update(&CORPUS[..end], true);
+ }
+ document.update(CORPUS, false);
+ let streamed: Vec<_> = document.blocks().cloned().collect();
+ assert_eq!(texts(&streamed), texts(&parse(CORPUS, 0).blocks));
+ }
+
+ #[test]
+ fn a_rewrite_before_the_settled_part_starts_over() {
+ let mut document = Document::default();
+ document.update("a\n\nb\n\nc\n\nd\n", false);
+ document.update("x\n\ny\n", false);
+ let blocks: Vec<_> = document.blocks().cloned().collect();
+ assert_eq!(
+ texts(&blocks),
+ vec![
+ (Kind::Paragraph, vec!["x".into()]),
+ (Kind::Paragraph, vec!["y".into()])
+ ]
+ );
+ }
+
+ #[test]
+ fn a_link_definition_keeps_the_whole_document_open() {
+ let mut document = Document::default();
+ let source = "see [x][r]\n\na\n\nb\n\nc\n\n[r]: https://r.example\n";
+ for end in 1..=source.len() {
+ document.update(&source[..end], true);
+ }
+ document.update(source, false);
+ let first = document.blocks().next().unwrap();
+ assert_eq!(first.texts[0].links[0].1, "https://r.example");
+ }
+}
diff --git a/crates/desktop/src/md/view.rs b/crates/desktop/src/md/view.rs
new file mode 100644
index 0000000000..bb53ce1cad
--- /dev/null
+++ b/crates/desktop/src/md/view.rs
@@ -0,0 +1,610 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Blocks to elements. Inline styles, the fade and the selection are all
+//! highlights over one text per block, so none of them can move a glyph.
+
+use super::{
+ fade::Arrivals,
+ parse::{Block, Document, Kind, Marker, Style, Text},
+};
+use crate::{
+ theme::{Theme, theme},
+ ui::{
+ Copied, icon,
+ select::{Key, Selection},
+ },
+};
+use gpui_kit::{
+ AnyElement, App, Div, ElementId, Entity, FontStyle, FontWeight, HighlightStyle,
+ InteractiveElement, InteractiveText, IntoElement, ParentElement, SharedString,
+ StatefulInteractiveElement, StrikethroughStyle, Styled, StyledText, div, px, relative,
+};
+use std::{ops::Range, time::Instant};
+
+#[derive(Clone, Copy)]
+pub struct Metrics {
+ pub text: f32,
+ pub line: f32,
+ pub code: f32,
+ pub code_line: f32,
+ pub gap: f32,
+}
+
+pub const BODY: Metrics = Metrics {
+ text: 14.,
+ line: 21.,
+ code: 13.,
+ code_line: 19.5,
+ gap: 10.,
+};
+
+pub const PROMPT: Metrics = Metrics {
+ text: 14.,
+ line: 20.,
+ code: 13.,
+ code_line: 19.5,
+ gap: 10.,
+};
+
+pub const COMPACT: Metrics = Metrics {
+ text: 13.5,
+ line: 19.5,
+ code: 13.,
+ code_line: 19.5,
+ gap: 7.,
+};
+
+/// A markdown message: its parsed blocks and, while it streams, when each
+/// part of it arrived.
+pub struct Body {
+ document: Document,
+ arrivals: Option<Arrivals>,
+}
+
+impl Body {
+ /// Text already on screen elsewhere, or loaded from history, starts
+ /// fully opaque.
+ pub fn new(source: &str, streaming: bool) -> Self {
+ let mut document = Document::default();
+ document.update(source, streaming);
+ Self {
+ document,
+ arrivals: streaming.then(Arrivals::seeded),
+ }
+ }
+
+ pub fn update(&mut self, source: &str, streaming: bool, now: Instant) {
+ if streaming {
+ let old = self.document.source().to_owned();
+ self.arrivals
+ .get_or_insert_with(Arrivals::seeded)
+ .record(&old, source, now);
+ } else {
+ self.arrivals = None;
+ }
+ self.document.update(source, streaming);
+ }
+
+ pub fn fading(&self, now: Instant) -> bool {
+ self.arrivals
+ .as_ref()
+ .is_some_and(|arrivals| arrivals.fading(now))
+ }
+}
+
+/// Byte ranges of a text still fading in, with their opacity.
+type Fades = Vec<(Range<usize>, f32)>;
+
+pub struct Scope<'a> {
+ pub row: &'a SharedString,
+ pub metrics: Metrics,
+ pub color: gpui_kit::Hsla,
+ pub selection: &'a Selection,
+ pub copied: &'a Entity<Copied>,
+ /// `None` when nothing fades: settled text or reduced motion.
+ pub now: Option<Instant>,
+}
+
+pub fn render(body: &Body, scope: &Scope, cx: &App) -> Div {
+ let theme = theme(cx);
+ let metrics = scope.metrics;
+ let mut column = div()
+ .flex()
+ .flex_col()
+ .min_w_0()
+ .text_size(px(metrics.text))
+ .line_height(px(metrics.line))
+ .text_color(scope.color);
+ let mut previous: Option<&Block> = None;
+ for (ix, block) in body.document.blocks().enumerate() {
+ let ordinal = (ix as u32) << 16;
+ let fades = |text: &Text| match (&body.arrivals, scope.now) {
+ (Some(arrivals), Some(now)) => arrivals.spans(text, now),
+ _ => Vec::new(),
+ };
+ let element = match &block.kind {
+ Kind::Paragraph => text(
+ scope,
+ ordinal,
+ &block.texts[0],
+ &fades(&block.texts[0]),
+ theme,
+ )
+ .into_any_element(),
+ Kind::Heading(level) => {
+ let (scale, weight) = match level {
+ 1 => (1.45, FontWeight::BOLD),
+ 2 => (1.28, FontWeight::BOLD),
+ 3 => (1.14, FontWeight::SEMIBOLD),
+ 4 => (1.05, FontWeight::SEMIBOLD),
+ _ => (1.0, FontWeight::SEMIBOLD),
+ };
+ let size = (metrics.text * scale).round();
+ div()
+ .text_size(px(size))
+ .line_height(px((size * 1.42).round()))
+ .font_weight(weight)
+ .when(*level <= 2, |this| this.pt(px(4.)))
+ .child(text(
+ scope,
+ ordinal,
+ &block.texts[0],
+ &fades(&block.texts[0]),
+ theme,
+ ))
+ .into_any_element()
+ }
+ Kind::Item(marker) => item(
+ *marker,
+ text(
+ scope,
+ ordinal,
+ &block.texts[0],
+ &fades(&block.texts[0]),
+ theme,
+ ),
+ metrics,
+ theme,
+ ),
+ Kind::Code { language } => code_block(
+ scope,
+ ordinal,
+ language.as_deref(),
+ &block.texts[0],
+ &fades(&block.texts[0]),
+ theme,
+ cx,
+ ),
+ Kind::Rule => div()
+ .h(px(1.))
+ .my(px(4.))
+ .bg(theme.border)
+ .into_any_element(),
+ Kind::Table { columns } => table(scope, ordinal, *columns, block, &fades, theme),
+ };
+ let indent = match &block.kind {
+ Kind::Item(_) => block.depth.saturating_sub(1),
+ _ => block.depth,
+ };
+ let element = quoted(block.quote, indented(indent, element), theme);
+ let gap = match (previous.map(|block| &block.kind), &block.kind) {
+ (None, _) => 0.,
+ (Some(Kind::Item(_)), Kind::Item(_)) => metrics.gap * 0.5,
+ _ => metrics.gap,
+ };
+ column = column.child(div().pt(px(gap)).min_w_0().child(element));
+ previous = Some(block);
+ }
+ column
+}
+
+fn text(
+ scope: &Scope,
+ ordinal: u32,
+ text: &Text,
+ fades: &[(Range<usize>, f32)],
+ theme: &Theme,
+) -> AnyElement {
+ let key = Key {
+ row: scope.row.clone(),
+ ordinal,
+ };
+ let selected = scope.selection.range_for(&key);
+ let content: SharedString = text.text.clone().into();
+ let mut code_ranges: Vec<Range<usize>> = Vec::new();
+ for (range, style) in &text.styles {
+ if *style == Style::Code {
+ match code_ranges.last_mut() {
+ Some(last) if last.end == range.start => last.end = range.end,
+ _ => code_ranges.push(range.clone()),
+ }
+ }
+ }
+ let styled = StyledText::new(content.clone())
+ .with_highlights(highlights(text, fades, selected, theme))
+ .with_font_family_overrides(
+ code_ranges
+ .into_iter()
+ .map(|range| (range, theme.mono_font.clone())),
+ );
+ let layout = styled.layout().clone();
+ let element = if text.links.is_empty() {
+ styled.into_any_element()
+ } else {
+ let urls: Vec<String> = text.links.iter().map(|(_, url)| url.clone()).collect();
+ InteractiveText::new(
+ ElementId::NamedInteger(scope.row.clone(), ordinal as u64),
+ styled,
+ )
+ .on_click(
+ text.links.iter().map(|(range, _)| range.clone()).collect(),
+ move |ix, _, cx| cx.open_url(&urls[ix]),
+ )
+ .into_any_element()
+ };
+ let block_start = ordinal & 0xffff == 0;
+ scope
+ .selection
+ .text(key, content, block_start, layout, element)
+ .into_any_element()
+}
+
+/// Styles that apply to disjoint pieces of `text`, in order.
+fn highlights(
+ text: &Text,
+ fades: &[(Range<usize>, f32)],
+ selected: Option<Range<usize>>,
+ theme: &Theme,
+) -> Vec<(Range<usize>, HighlightStyle)> {
+ let mut cuts = vec![0, text.text.len()];
+ for (range, _) in &text.styles {
+ cuts.extend([range.start, range.end]);
+ }
+ for (range, _) in fades {
+ cuts.extend([range.start, range.end]);
+ }
+ if let Some(range) = &selected {
+ cuts.extend([range.start, range.end]);
+ }
+ cuts.sort_unstable();
+ cuts.dedup();
+ let mut out = Vec::new();
+ for pair in cuts.windows(2) {
+ let piece = pair[0]..pair[1];
+ let mut style = HighlightStyle::default();
+ for (range, kind) in &text.styles {
+ if range.start <= piece.start && piece.end <= range.end {
+ style = style.highlight(match kind {
+ Style::Strong => HighlightStyle {
+ font_weight: Some(FontWeight::SEMIBOLD),
+ ..Default::default()
+ },
+ Style::Emphasis => HighlightStyle {
+ font_style: Some(FontStyle::Italic),
+ ..Default::default()
+ },
+ Style::Strike => HighlightStyle {
+ strikethrough: Some(StrikethroughStyle {
+ thickness: px(1.),
+ color: Some(theme.muted),
+ }),
+ ..Default::default()
+ },
+ Style::Code => HighlightStyle {
+ background_color: Some(theme.text.opacity(0.07)),
+ ..Default::default()
+ },
+ Style::Link => HighlightStyle {
+ color: Some(theme.accent_solid),
+ ..Default::default()
+ },
+ });
+ }
+ }
+ if let Some((_, opacity)) = fades
+ .iter()
+ .find(|(range, _)| range.start <= piece.start && piece.end <= range.end)
+ {
+ style.fade_out = Some(1. - opacity);
+ }
+ if selected
+ .as_ref()
+ .is_some_and(|range| range.start <= piece.start && piece.end <= range.end)
+ {
+ style.background_color = Some(theme.selection);
+ }
+ if style != HighlightStyle::default() {
+ out.push((piece, style));
+ }
+ }
+ out
+}
+
+fn item(marker: Marker, content: AnyElement, metrics: Metrics, theme: &Theme) -> AnyElement {
+ let gutter = match marker {
+ Marker::Number(_) => 22.,
+ _ => 14.,
+ };
+ let marker = match marker {
+ Marker::Bullet => div().text_color(theme.muted).child("•").into_any_element(),
+ Marker::Number(number) => div()
+ .text_color(theme.muted)
+ .child(format!("{number}."))
+ .into_any_element(),
+ Marker::Task(done) => {
+ let size = (metrics.text * 0.92).round();
+ div()
+ .mt(px(((metrics.line - size) / 2.).floor()))
+ .size(px(size))
+ .rounded(px(3.))
+ .border_1()
+ .flex()
+ .items_center()
+ .justify_center()
+ .border_color(if done { theme.accent } else { theme.border })
+ .when(done, |this| {
+ this.bg(theme.accent)
+ .child(icon("icons/check.svg", theme.on_accent).size(px(size - 4.)))
+ })
+ .into_any_element()
+ }
+ };
+ div()
+ .flex()
+ .items_start()
+ .min_w_0()
+ .child(div().w(px(gutter)).flex_none().child(marker))
+ .child(div().flex_auto().min_w_0().child(content))
+ .into_any_element()
+}
+
+fn indented(depth: u8, element: AnyElement) -> AnyElement {
+ if depth == 0 {
+ return element;
+ }
+ div()
+ .pl(px(18. * depth as f32))
+ .min_w_0()
+ .child(element)
+ .into_any_element()
+}
+
+fn quoted(depth: u8, element: AnyElement, theme: &Theme) -> AnyElement {
+ if depth == 0 {
+ return element;
+ }
+ let mut row = div().flex().gap(px(10.)).min_w_0();
+ for _ in 0..depth {
+ row = row.child(div().w(px(2.)).flex_none().rounded_full().bg(theme.border));
+ }
+ row.child(div().flex_auto().min_w_0().child(element))
+ .into_any_element()
+}
+
+fn code_block(
+ scope: &Scope,
+ ordinal: u32,
+ language: Option<&str>,
+ code: &Text,
+ fades: &[(Range<usize>, f32)],
+ theme: &Theme,
+ cx: &App,
+) -> AnyElement {
+ let copy_key: SharedString = format!("{}:{ordinal}", scope.row).into();
+ let copied = scope.copied.read(cx).is(©_key);
+ let source = scope.copied.clone();
+ let raw = code.text.clone();
+ div()
+ .flex()
+ .flex_col()
+ .min_w_0()
+ .rounded(px(8.))
+ .border_1()
+ .border_color(theme.border)
+ .bg(theme.sunken)
+ .overflow_hidden()
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .h(px(28.))
+ .pl(px(10.))
+ .pr(px(2.))
+ .border_b_1()
+ .border_color(theme.border)
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .truncate()
+ .text_size(px(12.5))
+ .line_height(px(14.))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(theme.muted)
+ .child(language.unwrap_or_default().to_lowercase()),
+ )
+ .child(
+ div()
+ .id(ElementId::NamedInteger(copy_key.clone(), 1))
+ .size(px(24.))
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(px(5.))
+ .cursor_pointer()
+ .hover(|this| this.bg(theme.hover))
+ .child(
+ icon(
+ if copied {
+ "icons/check.svg"
+ } else {
+ "icons/copy.svg"
+ },
+ theme.muted,
+ )
+ .size(px(12.)),
+ )
+ .on_mouse_down(gpui_kit::MouseButton::Left, |_, _, cx| {
+ cx.stop_propagation()
+ })
+ .on_click(move |_, _, cx| {
+ source.update(cx, |copied, cx| {
+ copied.copy(copy_key.clone(), raw.clone(), cx)
+ })
+ }),
+ ),
+ )
+ .child(
+ div()
+ .px(px(10.))
+ .py(px(8.))
+ .font_family(theme.mono_font.clone())
+ .text_size(px(scope.metrics.code))
+ .line_height(px(scope.metrics.code_line))
+ .text_color(theme.text)
+ .child(text(scope, ordinal, code, fades, theme)),
+ )
+ .into_any_element()
+}
+
+fn table(
+ scope: &Scope,
+ ordinal: u32,
+ columns: usize,
+ block: &Block,
+ fades: &dyn Fn(&Text) -> Fades,
+ theme: &Theme,
+) -> AnyElement {
+ if columns == 0 {
+ return div().into_any_element();
+ }
+ let widths = column_widths(columns, &block.texts);
+ let rows = block.texts.chunks(columns).count();
+ let mut table = div()
+ .flex()
+ .flex_col()
+ .w_full()
+ .rounded(px(8.))
+ .border_1()
+ .border_color(theme.border)
+ .overflow_hidden()
+ .text_size(px((scope.metrics.text - 0.5).max(12.5)))
+ .line_height(px(scope.metrics.line - 2.));
+ for (row_ix, cells) in block.texts.chunks(columns).enumerate() {
+ let mut row = div()
+ .flex()
+ .w_full()
+ .when(row_ix == 0, |this| {
+ this.bg(theme.text.opacity(0.04))
+ .font_weight(FontWeight::SEMIBOLD)
+ })
+ .when(row_ix + 1 < rows, |this| {
+ this.border_b_1().border_color(theme.border)
+ });
+ for (column, cell) in cells.iter().enumerate() {
+ let cell_ordinal = ordinal + (row_ix * columns + column) as u32;
+ row = row.child(
+ div()
+ .w(relative(widths[column]))
+ .min_w_0()
+ .px(px(9.))
+ .py(px(6.))
+ .child(text(scope, cell_ordinal, cell, &fades(cell), theme)),
+ );
+ }
+ table = table.child(row);
+ }
+ table.into_any_element()
+}
+
+/// Column shares proportional to their longest cell, none below 55% of an
+/// even share; a column held at the floor frees its excess for the others.
+fn column_widths(columns: usize, cells: &[Text]) -> Vec<f32> {
+ let mut content = vec![0f32; columns];
+ for (ix, cell) in cells.iter().enumerate() {
+ let width = cell.text.chars().count() as f32;
+ content[ix % columns] = content[ix % columns].max(width);
+ }
+ let floor = 0.55 / columns as f32;
+ let mut pinned = vec![false; columns];
+ loop {
+ let free: f32 = (0..columns)
+ .filter(|ix| !pinned[*ix])
+ .map(|ix| content[ix])
+ .sum();
+ let budget = 1. - floor * pinned.iter().filter(|pin| **pin).count() as f32;
+ let share = |ix: usize| {
+ if pinned[ix] {
+ floor
+ } else if free == 0. {
+ budget / pinned.iter().filter(|pin| !**pin).count() as f32
+ } else {
+ budget * content[ix] / free
+ }
+ };
+ let newly: Vec<usize> = (0..columns)
+ .filter(|ix| !pinned[*ix] && share(*ix) < floor)
+ .collect();
+ if newly.is_empty() {
+ return (0..columns).map(share).collect();
+ }
+ for ix in newly {
+ pinned[ix] = true;
+ }
+ }
+}
+
+use gpui_kit::prelude::FluentBuilder;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cells(texts: &[&str]) -> Vec<Text> {
+ texts
+ .iter()
+ .map(|text| Text {
+ text: (*text).into(),
+ ..Default::default()
+ })
+ .collect()
+ }
+
+ #[test]
+ fn columns_share_by_content_above_a_floor() {
+ let widths = column_widths(2, &cells(&["abcd", "abcdef"]));
+ assert!((widths[0] - 0.4).abs() < 1e-4 && (widths[1] - 0.6).abs() < 1e-4);
+ let widths = column_widths(2, &cells(&["a", &"x".repeat(200)]));
+ assert!((widths[0] - 0.275).abs() < 1e-4, "{widths:?}");
+ assert!((widths.iter().sum::<f32>() - 1.).abs() < 1e-4);
+ assert_eq!(column_widths(2, &cells(&["", ""])), vec![0.5, 0.5]);
+ }
+
+ #[test]
+ fn highlights_split_at_every_style_fade_and_selection_edge() {
+ let text = &crate::md::parse::parse("a **bold** `c`", 0).blocks[0].texts[0];
+ let theme = Theme::for_appearance(gpui_kit::WindowAppearance::Light);
+ let pieces = highlights(text, &[(2..6, 0.5)], Some(3..8), &theme);
+ let ranges: Vec<_> = pieces.iter().map(|(range, _)| range.clone()).collect();
+ assert_eq!(ranges, vec![2..3, 3..6, 6..7, 7..8]);
+ assert!(pieces[1].1.background_color == Some(theme.selection));
+ assert!(pieces[0].1.fade_out == Some(0.5));
+ }
+}
diff --git a/crates/desktop/src/sidebar.rs b/crates/desktop/src/sidebar.rs
new file mode 100644
index 0000000000..b0032b642c
--- /dev/null
+++ b/crates/desktop/src/sidebar.rs
@@ -0,0 +1,402 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Sessions, newest reply first, grouped by day. Every row has the same
+//! height, so the list knows where any row is before it is drawn.
+
+use crate::{
+ theme::theme,
+ ui::{self, icon, motion},
+};
+use chrono::{Datelike, Local, TimeZone};
+use gpui_kit::{
+ AnyElement, Context, EventEmitter, FontWeight, InteractiveElement, IntoElement, ListAlignment,
+ ListState, ParentElement, Render, SharedString, Styled, Task, Transformation, Window, div,
+ list, percentage, prelude::FluentBuilder, px,
+};
+use maka_protocol::session::{SessionCatalogProjection, SessionStatus};
+use std::time::Duration;
+
+pub const WIDTH: f32 = 252.;
+/// Clearance for the macOS window buttons at the top left.
+pub const TITLEBAR: f32 = 48.;
+
+pub enum SidebarEvent {
+ Open(String),
+ Create,
+}
+
+enum Item {
+ Group(&'static str),
+ Session(usize),
+}
+
+pub struct Sidebar {
+ sessions: Vec<SessionCatalogProjection>,
+ items: Vec<Item>,
+ selected: Option<String>,
+ can_create: bool,
+ error: Option<SharedString>,
+ list: ListState,
+ /// The minute boundary a timer is set for.
+ wake: Option<(u64, Task<()>)>,
+}
+
+impl EventEmitter<SidebarEvent> for Sidebar {}
+
+impl Sidebar {
+ pub fn new() -> Self {
+ Self {
+ sessions: Vec::new(),
+ items: Vec::new(),
+ selected: None,
+ can_create: false,
+ error: None,
+ list: ListState::new(0, ListAlignment::Top, px(520.)),
+ wake: None,
+ }
+ }
+
+ pub fn set_sessions(
+ &mut self,
+ mut sessions: Vec<SessionCatalogProjection>,
+ cx: &mut Context<Self>,
+ ) {
+ sessions.retain(|session| !session.is_archived);
+ sessions.sort_by_key(|session| std::cmp::Reverse(recency(session)));
+ let today = Local::now().date_naive();
+ let mut items = Vec::new();
+ let mut current = None;
+ for (ix, session) in sessions.iter().enumerate() {
+ let group = day_group(recency(session), today);
+ if current != Some(group) {
+ items.push(Item::Group(group));
+ current = Some(group);
+ }
+ items.push(Item::Session(ix));
+ }
+ self.sessions = sessions;
+ self.list.reset(items.len());
+ self.items = items;
+ self.error = None;
+ cx.notify();
+ }
+
+ pub fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
+ self.error = error;
+ cx.notify();
+ }
+
+ pub fn insert(&mut self, session: SessionCatalogProjection, cx: &mut Context<Self>) {
+ let mut sessions = std::mem::take(&mut self.sessions);
+ sessions.retain(|known| known.id != session.id);
+ sessions.push(session);
+ self.set_sessions(sessions, cx);
+ }
+
+ pub fn select(&mut self, session: Option<String>, cx: &mut Context<Self>) {
+ self.selected = session;
+ cx.notify();
+ }
+
+ pub fn set_can_create(&mut self, can_create: bool, cx: &mut Context<Self>) {
+ self.can_create = can_create;
+ cx.notify();
+ }
+
+ pub fn name(&self, id: &str) -> Option<SharedString> {
+ self.sessions
+ .iter()
+ .find(|session| session.id == id)
+ .map(|session| title(session).into())
+ }
+
+ fn render_item(
+ &mut self,
+ ix: usize,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ match self.items.get(ix) {
+ Some(Item::Group(label)) => div()
+ .h(px(30.))
+ .px(px(8.))
+ .pt(px(8.))
+ .text_size(px(12.5))
+ .line_height(px(16.))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(theme(cx).muted)
+ .child(*label)
+ .into_any_element(),
+ Some(Item::Session(session)) => {
+ let session = &self.sessions[*session];
+ let turn = if busy(session) {
+ motion::now(window, cx)
+ .map(|elapsed| motion::cycle(elapsed, Duration::from_millis(900)))
+ } else {
+ None
+ };
+ let now = Local::now().timestamp_millis().max(0) as u64;
+ self.row(session, turn, now, cx)
+ }
+ None => div().into_any_element(),
+ }
+ }
+
+ fn row(
+ &self,
+ session: &SessionCatalogProjection,
+ turn: Option<f32>,
+ now: u64,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let theme = theme(cx);
+ let selected = self.selected.as_deref() == Some(session.id.as_str());
+ let status = match session.status {
+ SessionStatus::Running => Some(
+ icon("icons/loader-circle.svg", theme.accent)
+ .size(px(12.))
+ .with_transformation(Transformation::rotate(percentage(turn.unwrap_or(0.))))
+ .into_any_element(),
+ ),
+ SessionStatus::WaitingForUser | SessionStatus::Blocked => Some(
+ icon("icons/triangle-alert.svg", theme.warning)
+ .size(px(12.))
+ .into_any_element(),
+ ),
+ _ => None,
+ };
+ let project = session
+ .workspace
+ .host_cwd
+ .rsplit(['/', '\\'])
+ .find(|part| !part.is_empty())
+ .unwrap_or("")
+ .to_owned();
+ let id = session.id.clone();
+ let entity = cx.weak_entity();
+ div()
+ .h(px(52.))
+ .pb(px(1.))
+ .child(ui::pressable(
+ div()
+ .id(SharedString::from(format!("session:{}", session.id)))
+ .h(px(51.))
+ .px(px(8.))
+ .py(px(7.))
+ .flex()
+ .flex_col()
+ .gap(px(4.))
+ .rounded(px(7.))
+ .border_1()
+ .border_color(gpui_kit::transparent_black())
+ .when(selected, |this| this.bg(theme.hover))
+ .hover(|style| style.bg(theme.hover))
+ .focus_visible(|style| style.border_color(theme.accent))
+ .child(
+ div()
+ .h(px(18.))
+ .flex()
+ .items_center()
+ .gap(px(6.))
+ .child(
+ div()
+ .min_w_0()
+ .truncate()
+ .text_size(px(13.5))
+ .line_height(px(18.))
+ .child(title(session)),
+ )
+ .children(status),
+ )
+ .child(
+ div()
+ .h(px(15.))
+ .flex()
+ .items_center()
+ .gap(px(5.))
+ .text_size(px(12.5))
+ .line_height(px(15.))
+ .text_color(theme.muted)
+ .child(icon("icons/folder.svg", theme.muted).size(px(12.)))
+ .child(div().flex_1().min_w_0().truncate().child(project))
+ .child(div().flex_none().child(ago(now, recency(session)))),
+ ),
+ move |_, cx| {
+ let id = id.clone();
+ let _ = entity.update(cx, |sidebar, cx| {
+ sidebar.selected = Some(id.clone());
+ cx.emit(SidebarEvent::Open(id));
+ cx.notify();
+ });
+ },
+ ))
+ .into_any_element()
+ }
+
+ /// Relative times change at minute boundaries; wake once for the next.
+ fn schedule_wake(&mut self, cx: &mut Context<Self>) {
+ let now = Local::now().timestamp_millis().max(0) as u64;
+ let boundary = now - now % 60_000 + 60_000;
+ if self.wake.as_ref().is_some_and(|(at, _)| *at == boundary) {
+ return;
+ }
+ let wait = boundary - now + 50;
+ let task = cx.spawn(async move |this, cx| {
+ cx.background_executor()
+ .timer(Duration::from_millis(wait))
+ .await;
+ let _ = this.update(cx, |_, cx| cx.notify());
+ });
+ self.wake = Some((boundary, task));
+ }
+}
+
+impl Render for Sidebar {
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ self.schedule_wake(cx);
+ let theme = theme(cx);
+ let entity = cx.weak_entity();
+ let can_create = self.can_create;
+ let new_row = ui::pressable(
+ div()
+ .id("new-session")
+ .h(px(32.))
+ .px(px(4.))
+ .flex()
+ .items_center()
+ .gap(px(10.))
+ .rounded(px(7.))
+ .border_1()
+ .border_color(gpui_kit::transparent_black())
+ .text_size(px(13.))
+ .text_color(theme.muted)
+ .when(can_create, |this| this.hover(|style| style.bg(theme.hover)))
+ .when(!can_create, |this| this.opacity(0.5))
+ .focus_visible(|style| style.border_color(theme.accent))
+ .child(
+ div()
+ .size(px(20.))
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(icon("icons/square-pen.svg", theme.muted)),
+ )
+ .child("新建会话"),
+ move |_, cx| {
+ if can_create {
+ let _ = entity.update(cx, |_, cx| cx.emit(SidebarEvent::Create));
+ }
+ },
+ );
+ div()
+ .size_full()
+ .flex()
+ .flex_col()
+ .bg(theme.sidebar)
+ .text_color(theme.text)
+ .child(crate::workspace::drag_region().h(px(TITLEBAR)).flex_none())
+ .child(div().px(px(10.)).child(new_row))
+ .when_some(self.error.clone(), |this, error| {
+ this.child(
+ div()
+ .px(px(14.))
+ .py(px(6.))
+ .text_size(px(12.5))
+ .text_color(theme.danger)
+ .child(error),
+ )
+ })
+ .child(
+ div().flex_1().min_h_0().px(px(10.)).pt(px(4.)).child(
+ list(self.list.clone(), {
+ let entity = cx.weak_entity();
+ move |ix, window, cx| {
+ entity
+ .update(cx, |sidebar, cx| sidebar.render_item(ix, window, cx))
+ .unwrap_or_else(|_| div().into_any_element())
+ }
+ })
+ .size_full(),
+ ),
+ )
+ }
+}
+
+fn title(session: &SessionCatalogProjection) -> String {
+ if session.name.trim().is_empty() {
+ "新会话".into()
+ } else {
+ session
+ .name
+ .split_whitespace()
+ .collect::<Vec<_>>()
+ .join(" ")
+ }
+}
+
+fn busy(session: &SessionCatalogProjection) -> bool {
+ session.status == SessionStatus::Running
+}
+
+/// When the last reply landed; renames do not move a session.
+fn recency(session: &SessionCatalogProjection) -> u64 {
+ session.last_message_at.unwrap_or(session.created_at)
+}
+
+fn day_group(ms: u64, today: chrono::NaiveDate) -> &'static str {
+ let Some(at) = Local.timestamp_millis_opt(ms as i64).single() else {
+ return "更早";
+ };
+ let date = at.date_naive();
+ if date >= today {
+ "今天"
+ } else if today.pred_opt() == Some(date) {
+ "昨天"
+ } else if date.iso_week() == today.iso_week() && date.year() == today.year() {
+ "本周"
+ } else if date.month() == today.month() && date.year() == today.year() {
+ "本月"
+ } else {
+ "更早"
+ }
+}
+
+fn ago(now: u64, then: u64) -> String {
+ let seconds = now.saturating_sub(then) / 1000;
+ match seconds {
+ 0..60 => "刚刚".into(),
+ 60..3600 => format!("{} 分钟", seconds / 60),
+ 3600..86400 => format!("{} 小时", seconds / 3600),
+ _ => format!("{} 天", seconds / 86400),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::ago;
+
+ #[test]
+ fn relative_times_round_down() {
+ assert_eq!(ago(59_000, 0), "刚刚");
+ assert_eq!(ago(61_000, 0), "1 分钟");
+ assert_eq!(ago(7_200_000, 0), "2 小时");
+ assert_eq!(ago(90_000_000, 0), "1 天");
+ }
+}
diff --git a/crates/desktop/src/theme.rs b/crates/desktop/src/theme.rs
new file mode 100644
index 0000000000..fd4bb7c86f
--- /dev/null
+++ b/crates/desktop/src/theme.rs
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Maka's palette (DESIGN.md §2, §3, §8): a neutral ladder, two text tiers,
+//! one accent and three statuses, derived from the same oklch values as the
+//! Electron app.
+
+use gpui_kit::{App, Global, Hsla, Rgba, SharedString, WindowAppearance};
+
+pub struct Theme {
+ pub dark: bool,
+ /// Window floor, behind the transcript.
+ pub base: Hsla,
+ pub sidebar: Hsla,
+ /// Reading surfaces: the transcript and the composer.
+ pub raised: Hsla,
+ /// Menus, tooltips, popovers.
+ pub overlay: Hsla,
+ /// Recessed wells: code blocks, table headers.
+ pub sunken: Hsla,
+ pub text: Hsla,
+ pub muted: Hsla,
+ pub border: Hsla,
+ /// Hover and selected rows share one wash.
+ pub hover: Hsla,
+ pub accent: Hsla,
+ /// Accent as text or as a filled button.
+ pub accent_solid: Hsla,
+ pub on_accent: Hsla,
+ pub selection: Hsla,
+ pub warning: Hsla,
+ pub danger: Hsla,
+ pub ui_font: SharedString,
+ pub mono_font: SharedString,
+}
+
+impl Global for Theme {}
+
+impl Theme {
+ pub fn for_appearance(appearance: WindowAppearance) -> Self {
+ let dark = matches!(
+ appearance,
+ WindowAppearance::Dark | WindowAppearance::VibrantDark
+ );
+ let pick = |light: (f32, f32, f32), dark_value: (f32, f32, f32)| {
+ let (l, c, h) = if dark { dark_value } else { light };
+ oklch(l, c, h, 1.)
+ };
+ let ink = pick((0.17, 0.005, 286.), (0.95, 0.004, 286.));
+ let background = if dark { 0.205 } else { 1.0 };
+ let neutral = |l: f32| oklch(l, if dark { 0.004 } else { 0. }, 286., 1.);
+ let accent = pick((0.70, 0.135, 250.), (0.74, 0.15, 250.));
+ Self {
+ dark,
+ base: neutral(background - 0.025),
+ sidebar: neutral(background - if dark { 0.04 } else { 0.045 }),
+ raised: neutral(background),
+ overlay: neutral(if dark { background + 0.018 } else { background }),
+ sunken: neutral(background - if dark { 0.065 } else { 0.055 }),
+ text: ink,
+ muted: mix(ink, neutral(background), 0.68),
+ border: ink.opacity(0.10),
+ hover: ink.opacity(0.05),
+ accent,
+ accent_solid: pick((0.48, 0.135, 250.), (0.76, 0.15, 250.)),
+ on_accent: if dark {
+ oklch(0.2, 0., 0., 1.)
+ } else {
+ oklch(1., 0., 0., 1.)
+ },
+ selection: accent.opacity(if dark { 0.40 } else { 0.28 }),
+ warning: pick((0.50, 0.18, 55.), (0.66, 0.18, 55.)),
+ danger: pick((0.50, 0.24, 28.), (0.70, 0.19, 22.)),
+ ui_font: ".SystemUIFont".into(),
+ mono_font: "Menlo".into(),
+ }
+ }
+}
+
+pub fn theme(cx: &App) -> &Theme {
+ cx.global::<Theme>()
+}
+
+/// `ink` over `ground`, `amount` of the way to ink.
+fn mix(ink: Hsla, ground: Hsla, amount: f32) -> Hsla {
+ let (a, b) = (Rgba::from(ink), Rgba::from(ground));
+ let lerp = |x: f32, y: f32| y + (x - y) * amount;
+ Rgba {
+ r: lerp(a.r, b.r),
+ g: lerp(a.g, b.g),
+ b: lerp(a.b, b.b),
+ a: 1.,
+ }
+ .into()
+}
+
+fn oklch(l: f32, c: f32, h: f32, alpha: f32) -> Hsla {
+ let (a, b) = (c * h.to_radians().cos(), c * h.to_radians().sin());
+ let l_ = (l + 0.396_337_78 * a + 0.215_803_76 * b).powi(3);
+ let m_ = (l - 0.105_561_346 * a - 0.063_854_17 * b).powi(3);
+ let s_ = (l - 0.089_484_18 * a - 1.291_485_5 * b).powi(3);
+ let linear = [
+ 4.076_741_7 * l_ - 3.307_711_6 * m_ + 0.230_969_94 * s_,
+ -1.268_438 * l_ + 2.609_757_4 * m_ - 0.341_319_38 * s_,
+ -0.004_196_086_3 * l_ - 0.703_418_6 * m_ + 1.707_614_7 * s_,
+ ];
+ let gamma = |x: f32| {
+ let x = x.clamp(0., 1.);
+ if x <= 0.003_130_8 {
+ 12.92 * x
+ } else {
+ 1.055 * x.powf(1. / 2.4) - 0.055
+ }
+ };
+ Rgba {
+ r: gamma(linear[0]),
+ g: gamma(linear[1]),
+ b: gamma(linear[2]),
+ a: alpha,
+ }
+ .into()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::oklch;
+ use gpui_kit::Rgba;
+
+ #[test]
+ fn oklch_matches_known_srgb() {
+ let white = Rgba::from(oklch(1., 0., 0., 1.));
+ assert!((white.r - 1.).abs() < 0.01 && (white.b - 1.).abs() < 0.01);
+ // DESIGN.md's brand blue family: oklch(0.70 0.135 250) ≈ #5ea0f0.
+ let blue = Rgba::from(oklch(0.70, 0.135, 250., 1.));
+ assert!(blue.b > blue.g && blue.g > blue.r, "{blue:?}");
+ }
+}
diff --git a/crates/desktop/src/ui/mod.rs b/crates/desktop/src/ui/mod.rs
new file mode 100644
index 0000000000..23312e0241
--- /dev/null
+++ b/crates/desktop/src/ui/mod.rs
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! The few controls the client draws itself. Every control answers the
+//! mouse and, once focused with Tab, bare Enter and Space; hover is an
+//! instant wash and the cursor stays an arrow, as in native apps.
+
+pub mod motion;
+pub mod scrollbar;
+pub mod select;
+
+use crate::theme::theme;
+use gpui_kit::{
+ AnyView, App, AppContext, ClickEvent, ClipboardItem, Context, Div, ElementId, FontWeight, Hsla,
+ InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Render, Role, SharedString,
+ Stateful, StatefulInteractiveElement, Styled, Svg, Window, div, prelude::FluentBuilder, px,
+ svg,
+};
+use std::{rc::Rc, time::Duration};
+
+gpui_kit::assets::icon_assets!(pub Icons, [
+ Pencil,
+ Wrench,
+ Sparkles,
+ Hourglass,
+ Square,
+ List,
+ MessageCircleQuestionMark,
+ SquarePen,
+]);
+
+/// Our icons first, then gpui-kit's default set.
+pub struct Assets;
+
+impl gpui_kit::AssetSource for Assets {
+ fn load(&self, path: &str) -> gpui_kit::Result<Option<std::borrow::Cow<'static, [u8]>>> {
+ match Icons.load(path)? {
+ Some(bytes) => Ok(Some(bytes)),
+ None => gpui_kit::assets::Assets.load(path),
+ }
+ }
+
+ fn list(&self, path: &str) -> gpui_kit::Result<Vec<SharedString>> {
+ let mut paths = Icons.list(path)?;
+ paths.extend(gpui_kit::assets::Assets.list(path)?);
+ Ok(paths)
+ }
+}
+
+/// A Lucide icon. An svg does not inherit its parent's text color, so the
+/// color is required; follow a parent's hover with `group_hover`.
+pub fn icon(path: &'static str, color: Hsla) -> Svg {
+ svg()
+ .path(SharedString::new_static(path))
+ .size(px(14.))
+ .flex_none()
+ .text_color(color)
+}
+
+type Handler = Rc<dyn Fn(&mut Window, &mut App)>;
+
+/// Makes `element` a control: clicked, or activated with bare Enter or Space
+/// once focused. Chords are left alone so their commands still run.
+pub fn pressable(
+ element: Stateful<Div>,
+ on_press: impl Fn(&mut Window, &mut App) + 'static,
+) -> Stateful<Div> {
+ let on_press: Handler = Rc::new(on_press);
+ let on_key = on_press.clone();
+ element
+ .role(Role::Button)
+ .tab_index(0)
+ .on_click(move |_: &ClickEvent, window, cx| {
+ cx.stop_propagation();
+ on_press(window, cx)
+ })
+ .on_key_down(move |event: &KeyDownEvent, window, cx| {
+ let key = &event.keystroke;
+ if matches!(key.key.as_str(), "enter" | "space") && !key.modifiers.modified() {
+ cx.stop_propagation();
+ on_key(window, cx);
+ }
+ })
+}
+
+#[derive(Clone, Copy, PartialEq)]
+pub enum Tone {
+ /// The one action the card is for.
+ Primary,
+ Outline,
+}
+
+/// A labeled button, 28 tall.
+pub fn button(
+ id: impl Into<ElementId>,
+ label: impl Into<SharedString>,
+ tone: Tone,
+ disabled: bool,
+ on_press: impl Fn(&mut Window, &mut App) + 'static,
+ cx: &App,
+) -> Stateful<Div> {
+ let theme = theme(cx);
+ let base = div()
+ .id(id)
+ .h(px(28.))
+ .px(px(13.))
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(px(7.))
+ .border_1()
+ .text_size(px(12.5))
+ .font_weight(FontWeight::SEMIBOLD)
+ .focus_visible(|style| style.border_color(theme.accent))
+ .map(|this| match tone {
+ Tone::Primary => this
+ .bg(theme.accent_solid)
+ .border_color(theme.accent_solid)
+ .text_color(theme.on_accent)
+ .when(!disabled, |this| this.hover(|style| style.opacity(0.9))),
+ Tone::Outline => this
+ .border_color(theme.border)
+ .text_color(theme.muted)
+ .when(!disabled, |this| {
+ this.hover(|style| style.bg(theme.hover).text_color(theme.text))
+ }),
+ })
+ .child(label.into());
+ if disabled {
+ base.opacity(0.5)
+ } else {
+ pressable(base, on_press).active(|style| style.opacity(0.8))
+ }
+}
+
+/// A square icon button: 26 with a 14 icon, or 22 with a 13 icon when small.
+pub fn icon_button(
+ id: impl Into<ElementId>,
+ path: &'static str,
+ small: bool,
+ on_press: impl Fn(&mut Window, &mut App) + 'static,
+ cx: &App,
+) -> Stateful<Div> {
+ let theme = theme(cx);
+ let (box_size, icon_size) = if small { (22., 13.) } else { (26., 14.) };
+ let id = id.into();
+ let group = SharedString::from(format!("{id}"));
+ pressable(
+ div()
+ .id(id)
+ .group(group.clone())
+ .size(px(box_size))
+ .flex_none()
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(px(6.))
+ .border_1()
+ .border_color(gpui_kit::transparent_black())
+ .hover(|style| style.bg(theme.hover))
+ .focus_visible(|style| style.border_color(theme.accent))
+ .child(
+ icon(path, theme.muted)
+ .size(px(icon_size))
+ .group_hover(group, |style| style.text_color(theme.text)),
+ ),
+ on_press,
+ )
+}
+
+/// A one-line tooltip for `.tooltip(tooltip("…"))`.
+pub fn tooltip(text: impl Into<SharedString>) -> impl Fn(&mut Window, &mut App) -> AnyView {
+ let text = text.into();
+ move |_, cx| {
+ let text = text.clone();
+ cx.new(|_| Tooltip(text)).into()
+ }
+}
+
+struct Tooltip(SharedString);
+
+impl Render for Tooltip {
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let theme = theme(cx);
+ div().pl(px(2.)).pt(px(4.)).child(
+ div()
+ .px(px(7.))
+ .py(px(4.))
+ .rounded(px(6.))
+ .border_1()
+ .border_color(theme.border)
+ .bg(theme.overlay)
+ .shadow_md()
+ .text_size(px(12.5))
+ .line_height(px(15.))
+ .text_color(theme.muted)
+ .child(self.0.clone()),
+ )
+ }
+}
+
+/// Which copy button shows its check mark. One per window: copying anything
+/// else takes the check away, and an older timer cannot clear a newer copy.
+#[derive(Default)]
+pub struct Copied {
+ key: Option<SharedString>,
+ generation: u64,
+}
+
+impl Copied {
+ pub fn is(&self, key: &str) -> bool {
+ self.key.as_deref() == Some(key)
+ }
+
+ pub fn copy(&mut self, key: SharedString, text: String, cx: &mut Context<Self>) {
+ cx.write_to_clipboard(ClipboardItem::new_string(text));
+ self.key = Some(key);
+ self.generation += 1;
+ let generation = self.generation;
+ cx.spawn(async move |this, cx| {
+ cx.background_executor().timer(Duration::from_secs(2)).await;
+ let _ = this.update(cx, |this, cx| {
+ if this.generation == generation {
+ this.key = None;
+ cx.notify();
+ }
+ });
+ })
+ .detach();
+ cx.notify();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui_kit::AssetSource;
+
+ #[test]
+ fn every_icon_the_client_draws_is_embedded() {
+ let mut missing = Vec::new();
+ for file in std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))
+ .into_iter()
+ .flatten()
+ .flatten()
+ .flat_map(|entry| walk(entry.path()))
+ {
+ let source = std::fs::read_to_string(&file).unwrap_or_default();
+ for path in source.split('"').filter(|part| {
+ part.starts_with("icons/") && part.ends_with(".svg") && !part.contains(' ')
+ }) {
+ if !matches!(super::Assets.load(path), Ok(Some(_))) {
+ missing.push(path.to_owned());
+ }
+ }
+ }
+ assert!(missing.is_empty(), "not embedded: {missing:?}");
+ }
+
+ fn walk(path: std::path::PathBuf) -> Vec<std::path::PathBuf> {
+ if path.is_dir() {
+ std::fs::read_dir(&path)
+ .into_iter()
+ .flatten()
+ .flatten()
+ .flat_map(|entry| walk(entry.path()))
+ .collect()
+ } else {
+ vec![path]
+ }
+ }
+}
diff --git a/crates/desktop/src/ui/motion.rs b/crates/desktop/src/ui/motion.rs
new file mode 100644
index 0000000000..3743e6a5fe
--- /dev/null
+++ b/crates/desktop/src/ui/motion.rs
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! One clock for every animation. A view that draws something moving asks for
+//! the current time while it renders; the clock then redraws that view, and
+//! only that view, about 30 times a second until it stops asking. Nothing
+//! re-arms `request_animation_frame`, which would redraw at display rate.
+
+use gpui_kit::{App, EntityId, Global, Window};
+use std::{
+ collections::HashMap,
+ time::{Duration, Instant},
+};
+
+const TICK: Duration = Duration::from_millis(33);
+/// A view that has not drawn anything moving for this long is let go.
+const LEASE: Duration = Duration::from_millis(300);
+
+struct Clock {
+ epoch: Instant,
+ leases: HashMap<EntityId, Instant>,
+ running: bool,
+}
+
+impl Global for Clock {}
+
+impl Default for Clock {
+ fn default() -> Self {
+ Self {
+ epoch: Instant::now(),
+ leases: HashMap::new(),
+ running: false,
+ }
+ }
+}
+
+/// Time since the clock started, and a redraw of the rendering view on the
+/// next tick. Every animation reads this one epoch, so loaders stay in step.
+/// Returns `None` when the system asks for reduced motion; callers then draw
+/// their resting state.
+pub fn now(window: &Window, cx: &mut App) -> Option<Duration> {
+ if cx.reduce_motion() {
+ return None;
+ }
+ let view = window.current_view();
+ let clock = cx.default_global::<Clock>();
+ let now = Instant::now();
+ clock.leases.insert(view, now);
+ let elapsed = now - clock.epoch;
+ if !clock.running {
+ clock.running = true;
+ cx.spawn(async move |cx| {
+ loop {
+ cx.background_executor().timer(TICK).await;
+ let idle = cx.update(|cx| {
+ let clock = cx.global_mut::<Clock>();
+ let now = Instant::now();
+ clock.leases.retain(|_, seen| now - *seen < LEASE);
+ let views: Vec<_> = clock.leases.keys().copied().collect();
+ clock.running = !views.is_empty();
+ for view in &views {
+ cx.notify(*view);
+ }
+ views.is_empty()
+ });
+ if idle {
+ return;
+ }
+ }
+ })
+ .detach();
+ }
+ Some(elapsed)
+}
+
+/// Position in a repeating cycle, from 0 up to 1.
+pub fn cycle(elapsed: Duration, period: Duration) -> f32 {
+ (elapsed.as_secs_f32() / period.as_secs_f32()).fract()
+}
diff --git a/crates/desktop/src/ui/scrollbar.rs b/crates/desktop/src/ui/scrollbar.rs
new file mode 100644
index 0000000000..ddfbe21269
--- /dev/null
+++ b/crates/desktop/src/ui/scrollbar.rs
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! A macOS-style overlay scrollbar for a `list()`: hidden at rest, shown
+//! while the content moves, then faded out. A streaming transcript moves on
+//! every update, so the bar holds at full opacity for the whole reply; the
+//! hold costs one timer, and only the fade draws frames.
+
+use super::motion;
+use crate::theme::theme;
+use gpui_kit::{
+ App, Bounds, DispatchPhase, EntityId, IntoElement, ListState, MouseButton, MouseDownEvent,
+ MouseMoveEvent, MouseUpEvent, Pixels, Styled, Task, Window, canvas, fill, point, px, size,
+};
+use std::{
+ cell::RefCell,
+ rc::Rc,
+ time::{Duration, Instant},
+};
+
+const HOLD: Duration = Duration::from_millis(900);
+const FADE: Duration = Duration::from_millis(350);
+const TRACK: f32 = 11.;
+const MIN_THUMB: f32 = 28.;
+
+#[derive(Default)]
+struct State {
+ offset: Option<Pixels>,
+ moved: Option<Instant>,
+ hovered: bool,
+ /// Where in the thumb the pointer grabbed it.
+ grab: Option<Pixels>,
+ wake: Option<Task<()>>,
+}
+
+#[derive(Clone, Default)]
+pub struct Scrollbar(Rc<RefCell<State>>);
+
+impl Scrollbar {
+ /// The bar, absolutely placed along the right edge of its parent.
+ /// `view` is the view to redraw while it changes.
+ pub fn render(
+ &self,
+ list: &ListState,
+ view: EntityId,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> impl IntoElement + use<> {
+ let viewport = list.viewport_bounds();
+ let max = list.max_offset_for_scrollbar().y;
+ let offset = -list.scroll_px_offset_for_scrollbar().y;
+ let opacity = self.observe(offset, max, view, window, cx);
+ let theme = theme(cx);
+ let state = self.0.clone();
+ let list = list.clone();
+ let (rest, engaged) = (theme.muted.opacity(0.45), theme.muted);
+ canvas(
+ |_, _, _| {},
+ move |track: Bounds<Pixels>, _, window, _| {
+ if max <= px(0.5) || viewport.size.height <= px(0.) {
+ let mut state = state.borrow_mut();
+ state.hovered = false;
+ state.grab = None;
+ return;
+ }
+ let height = track.size.height;
+ let content = height + max;
+ let thumb_height = (height * (height / content)).max(px(MIN_THUMB)).min(height);
+ let travel = height - thumb_height;
+ let progress = (offset / max).clamp(0., 1.);
+ let thumb_top = track.top() + travel * progress;
+ let (hovered, grabbed) = {
+ let state = state.borrow();
+ (state.hovered, state.grab.is_some())
+ };
+ let width = if hovered || grabbed { 8. } else { 5. };
+ let color = if hovered || grabbed { engaged } else { rest };
+ if opacity > 0. {
+ window.paint_quad(
+ fill(
+ Bounds::new(
+ point(track.right() - px(2. + width), thumb_top),
+ size(px(width), thumb_height),
+ ),
+ color.opacity(color.a * opacity),
+ )
+ .corner_radii(px(width / 2.)),
+ );
+ }
+ let thumb = Bounds::new(
+ point(track.left(), thumb_top),
+ size(track.size.width, thumb_height),
+ );
+ let set_offset = {
+ let list = list.clone();
+ move |y: Pixels, grab: Pixels| {
+ let ratio = if travel > px(0.) {
+ ((y - grab - track.top()) / travel).clamp(0., 1.)
+ } else {
+ 0.
+ };
+ list.set_offset_from_scrollbar(point(px(0.), -(max * ratio)));
+ }
+ };
+ window.on_mouse_event({
+ let state = state.clone();
+ let list = list.clone();
+ let set_offset = set_offset.clone();
+ move |event: &MouseDownEvent, phase, window, cx| {
+ if phase != DispatchPhase::Bubble
+ || event.button != MouseButton::Left
+ || !track.contains(&event.position)
+ {
+ return;
+ }
+ let grab = if thumb.contains(&event.position) {
+ event.position.y - thumb.top()
+ } else {
+ thumb_height / 2.
+ };
+ state.borrow_mut().grab = Some(grab);
+ list.scrollbar_drag_started();
+ set_offset(event.position.y, grab);
+ cx.stop_propagation();
+ window.refresh();
+ }
+ });
+ window.on_mouse_event({
+ let state = state.clone();
+ move |event: &MouseMoveEvent, phase, window, _| {
+ if phase != DispatchPhase::Bubble {
+ return;
+ }
+ let grab = state.borrow().grab;
+ if let Some(grab) = grab {
+ set_offset(event.position.y, grab);
+ window.refresh();
+ return;
+ }
+ let hovered = track.contains(&event.position);
+ if state.borrow().hovered != hovered {
+ state.borrow_mut().hovered = hovered;
+ window.refresh();
+ }
+ }
+ });
+ window.on_mouse_event({
+ let state = state.clone();
+ move |_: &MouseUpEvent, phase, window, _| {
+ if phase == DispatchPhase::Bubble
+ && state.borrow_mut().grab.take().is_some()
+ {
+ list.scrollbar_drag_ended();
+ window.refresh();
+ }
+ }
+ });
+ },
+ )
+ .absolute()
+ .top_0()
+ .right_0()
+ .h_full()
+ .w(px(TRACK))
+ }
+
+ /// Records the offset and returns the bar's opacity now, arranging the
+ /// next redraw it needs.
+ fn observe(
+ &self,
+ offset: Pixels,
+ max: Pixels,
+ view: EntityId,
+ window: &Window,
+ cx: &mut App,
+ ) -> f32 {
+ let mut state = self.0.borrow_mut();
+ let now = Instant::now();
+ match state.offset {
+ // The first sight of the list is where it opened, not a scroll.
+ None => state.offset = Some(offset),
+ Some(last) if (last - offset).abs() > px(0.5) => {
+ state.offset = Some(offset);
+ state.moved = Some(now);
+ }
+ _ => {}
+ }
+ if max <= px(0.5) {
+ return 0.;
+ }
+ if state.hovered || state.grab.is_some() {
+ return 1.;
+ }
+ let Some(moved) = state.moved else {
+ return 0.;
+ };
+ let since = now - moved;
+ if since < HOLD {
+ let wait = HOLD - since + Duration::from_millis(16);
+ state.wake = Some(cx.spawn(async move |cx| {
+ cx.background_executor().timer(wait).await;
+ cx.update(|cx| cx.notify(view));
+ }));
+ return 1.;
+ }
+ state.wake = None;
+ if since < HOLD + FADE {
+ drop(state);
+ motion::now(window, cx);
+ return 1. - (since - HOLD).as_secs_f32() / FADE.as_secs_f32();
+ }
+ 0.
+ }
+}
diff --git a/crates/desktop/src/ui/select.rs b/crates/desktop/src/ui/select.rs
new file mode 100644
index 0000000000..bda7d6e6c3
--- /dev/null
+++ b/crates/desktop/src/ui/select.rs
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Text selection across many text elements, for surfaces GPUI gives none:
+//! the transcript's messages, blocks and table cells. Every text element
+//! painted in a frame registers itself in paint order, which is document
+//! order; a drag resolves into spans that keep a copy of their text, so a
+//! selection survives its rows scrolling out of the list.
+
+use gpui_kit::{
+ Bounds, IntoElement, ParentElement, Pixels, Point, SharedString, Styled, TextLayout, canvas,
+ div,
+};
+use std::{cell::RefCell, ops::Range, rc::Rc};
+use unicode_segmentation::UnicodeSegmentation;
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct Key {
+ pub row: SharedString,
+ pub ordinal: u32,
+}
+
+struct Entry {
+ key: Key,
+ text: SharedString,
+ /// First text of a block; copy puts a blank line before it.
+ block_start: bool,
+ layout: TextLayout,
+}
+
+impl Entry {
+ fn bounds(&self) -> Bounds<Pixels> {
+ self.layout.bounds()
+ }
+
+ fn offset(&self, point: Point<Pixels>) -> usize {
+ let offset = match self.layout.index_for_position(point) {
+ Ok(offset) | Err(offset) => offset.min(self.text.len()),
+ };
+ floor_char(&self.text, offset)
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Span {
+ pub key: Key,
+ pub range: Range<usize>,
+ text: SharedString,
+ block_start: bool,
+}
+
+#[derive(Default)]
+pub struct Selection {
+ registry: Rc<RefCell<Vec<Entry>>>,
+ anchor: Option<(Key, usize)>,
+ dragging: bool,
+ spans: Vec<Span>,
+}
+
+impl Selection {
+ /// Paint this before any selectable text of the frame.
+ pub fn frame_start(&self) -> impl IntoElement {
+ let registry = self.registry.clone();
+ canvas(
+ |_, _, _| {},
+ move |_, _, _, _| registry.borrow_mut().clear(),
+ )
+ .absolute()
+ .size_0()
+ }
+
+ pub fn range_for(&self, key: &Key) -> Option<Range<usize>> {
+ self.spans
+ .iter()
+ .find(|span| &span.key == key)
+ .map(|span| span.range.clone())
+ }
+
+ /// Wraps a text element so it takes part in selection. `element` is the
+ /// `StyledText` whose layout this is, possibly wrapped for link clicks.
+ pub fn text(
+ &self,
+ key: Key,
+ text: SharedString,
+ block_start: bool,
+ layout: TextLayout,
+ element: impl IntoElement,
+ ) -> impl IntoElement {
+ let registry = self.registry.clone();
+ div()
+ .relative()
+ .min_w_0()
+ .cursor_text()
+ .child(element)
+ .child(
+ canvas(
+ |_, _, _| {},
+ move |_, _, _, _| {
+ registry.borrow_mut().push(Entry {
+ key,
+ text,
+ block_start,
+ layout,
+ })
+ },
+ )
+ .absolute()
+ .size_0(),
+ )
+ }
+
+ /// Returns whether the selection changed.
+ pub fn mouse_down(&mut self, position: Point<Pixels>, clicks: usize) -> bool {
+ let registry = self.registry.borrow();
+ let Some(entry) = registry
+ .iter()
+ .find(|entry| entry.bounds().contains(&position))
+ else {
+ drop(registry);
+ self.anchor = None;
+ self.dragging = false;
+ return !std::mem::take(&mut self.spans).is_empty();
+ };
+ let offset = entry.offset(position);
+ let range = match clicks {
+ 1 => offset..offset,
+ 2 => word(&entry.text, offset),
+ _ => line(&entry.text, offset),
+ };
+ let spans = if range.is_empty() {
+ Vec::new()
+ } else {
+ vec![Span {
+ key: entry.key.clone(),
+ range: range.clone(),
+ text: entry.text.clone(),
+ block_start: false,
+ }]
+ };
+ self.anchor = Some((entry.key.clone(), range.start));
+ drop(registry);
+ self.dragging = true;
+ let changed = self.spans != spans;
+ self.spans = spans;
+ changed
+ }
+
+ pub fn mouse_move(&mut self, position: Point<Pixels>) -> bool {
+ if !self.dragging {
+ return false;
+ }
+ let Some((anchor_key, anchor_offset)) = self.anchor.clone() else {
+ return false;
+ };
+ let registry = self.registry.borrow();
+ let Some(anchor) = registry.iter().position(|entry| entry.key == anchor_key) else {
+ return false;
+ };
+ let head = registry
+ .iter()
+ .position(|entry| {
+ let bounds = entry.bounds();
+ position.y >= bounds.top()
+ && position.y < bounds.bottom()
+ && position.x >= bounds.left() - gpui_kit::px(24.)
+ })
+ .or_else(|| {
+ registry
+ .iter()
+ .enumerate()
+ .min_by_key(|(_, entry)| {
+ let bounds = entry.bounds();
+ let distance = if position.y < bounds.top() {
+ bounds.top() - position.y
+ } else {
+ position.y - bounds.bottom()
+ };
+ (f32::from(distance).max(0.) * 100.) as i64
+ })
+ .map(|(ix, _)| ix)
+ });
+ let Some(head) = head else {
+ return false;
+ };
+ let head_offset = registry[head].offset(position);
+ let (first, first_offset, last, last_offset) =
+ if (head, head_offset) < (anchor, anchor_offset) {
+ (head, head_offset, anchor, anchor_offset)
+ } else {
+ (anchor, anchor_offset, head, head_offset)
+ };
+ let mut spans = Vec::new();
+ for (ix, entry) in registry.iter().enumerate().take(last + 1).skip(first) {
+ let start = if ix == first { first_offset } else { 0 };
+ let end = if ix == last {
+ last_offset
+ } else {
+ entry.text.len()
+ };
+ let crossed = ix != first && ix != last;
+ if start < end || (crossed && entry.text.is_empty()) {
+ spans.push(Span {
+ key: entry.key.clone(),
+ range: floor_char(&entry.text, start)..floor_char(&entry.text, end),
+ text: entry.text.clone(),
+ block_start: entry.block_start && !spans.is_empty(),
+ });
+ }
+ }
+ drop(registry);
+ if spans == self.spans {
+ return false;
+ }
+ self.spans = spans;
+ true
+ }
+
+ pub fn mouse_up(&mut self) {
+ self.dragging = false;
+ }
+
+ /// The selected text as it reads on screen.
+ pub fn copied(&self) -> Option<String> {
+ let mut out = String::new();
+ for (ix, span) in self.spans.iter().enumerate() {
+ if ix > 0 {
+ out.push_str(if span.block_start { "\n\n" } else { "\n" });
+ }
+ out.push_str(&span.text[span.range.clone()]);
+ }
+ (!out.is_empty()).then_some(out)
+ }
+}
+
+fn floor_char(text: &str, mut offset: usize) -> usize {
+ offset = offset.min(text.len());
+ while !text.is_char_boundary(offset) {
+ offset -= 1;
+ }
+ offset
+}
+
+fn word(text: &str, offset: usize) -> Range<usize> {
+ text.split_word_bound_indices()
+ .map(|(start, word)| start..start + word.len())
+ .find(|range| range.contains(&offset) || range.end == offset && range.start < offset)
+ .filter(|range| !text[range.clone()].trim().is_empty())
+ .unwrap_or(offset..offset)
+}
+
+fn line(text: &str, offset: usize) -> Range<usize> {
+ let start = text[..offset].rfind('\n').map_or(0, |at| at + 1);
+ let end = text[offset..]
+ .find('\n')
+ .map_or(text.len(), |at| offset + at);
+ start..end
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn span(row: &str, ordinal: u32, text: &str, range: Range<usize>, block_start: bool) -> Span {
+ Span {
+ key: Key {
+ row: row.to_owned().into(),
+ ordinal,
+ },
+ range,
+ text: text.to_owned().into(),
+ block_start,
+ }
+ }
+
+ #[test]
+ fn copies_rendered_text_with_block_breaks() {
+ let selection = Selection {
+ spans: vec![
+ span("a", 0, "Hello world", 6..11, false),
+ span("a", 1, "cell one", 0..8, true),
+ span("a", 2, "cell two", 0..4, false),
+ ],
+ ..Default::default()
+ };
+ assert_eq!(selection.copied().unwrap(), "world\n\ncell one\ncell");
+ }
+
+ #[test]
+ fn double_click_takes_a_word_and_triple_a_line() {
+ assert_eq!(word("run cargo_test now", 6), 4..14);
+ assert_eq!(word("a b", 2), 2..2);
+ assert_eq!(line("one\ntwo three\nfour", 6), 4..13);
+ }
+}
diff --git a/crates/desktop/src/workspace.rs b/crates/desktop/src/workspace.rs
index 11c24bd669..0cf331cdff 100644
--- a/crates/desktop/src/workspace.rs
+++ b/crates/desktop/src/workspace.rs
@@ -17,28 +17,31 @@
* under the License.
*/
+//! The window: sidebar, header and the open session. The root holds only
+//! the connection; the sidebar and the chat are their own views, drawn from
+//! cache unless they changed, so a streaming reply redraws the chat alone.
+
use crate::{
chat::Chat,
host::{self, Host},
+ sidebar::{self, Sidebar, SidebarEvent},
+ theme::theme,
+ ui::{self, Copied},
};
-use gpui_kit::component::{
- ActiveTheme, Disableable, IconName, Sizable, StyledExt,
- button::{Button, ButtonVariants},
- h_flex,
- sidebar::{Sidebar, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem},
- v_flex,
+use gpui_kit::{
+ AnyElement, AppContext, AsyncApp, Context, Div, Entity, FontWeight, InteractiveElement,
+ IntoElement, MouseButton, ParentElement, Render, Role, SharedString, Stateful,
+ StatefulInteractiveElement, StyleRefinement, Styled, Task, WeakEntity, Window, div, px,
};
-use gpui_kit::{prelude::FluentBuilder, *};
use maka_client::{Client, Notification};
-use maka_protocol::session::{
- SessionCatalogProjection, SessionCatalogQueryInput, SessionCatalogQueryResult,
-};
+use maka_protocol::session::{SessionCatalogQueryInput, SessionCatalogQueryResult};
use serde_json::json;
-use std::{path::PathBuf, time::Duration};
+use std::{cell::Cell, path::PathBuf, rc::Rc, time::Duration};
use tokio::sync::mpsc;
-/// Deltas arrive at the provider's chunk rate; one repaint per window is enough.
-const STREAM_FRAME: Duration = Duration::from_millis(100);
+/// Provider chunks queue this long and land as one update: one parse and
+/// one redraw per update, whatever the chunk rate.
+const STREAM_FRAME: Duration = Duration::from_millis(120);
enum Connection {
Connecting,
@@ -49,20 +52,26 @@ enum Connection {
pub struct Workspace {
root: PathBuf,
connection: Connection,
- sessions: Vec<SessionCatalogProjection>,
- sessions_error: Option<SharedString>,
+ sidebar: Entity<Sidebar>,
chat: Option<Entity<Chat>>,
+ copied: Entity<Copied>,
_tasks: Vec<Task<()>>,
}
impl Workspace {
- pub fn new(root: PathBuf, cx: &mut Context<Self>) -> Self {
+ pub fn new(root: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ let sidebar = cx.new(|_| Sidebar::new());
+ cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
+ SidebarEvent::Open(id) => this.open(id.clone(), window, cx),
+ SidebarEvent::Create => this.create_session(window, cx),
+ })
+ .detach();
let mut this = Self {
root,
connection: Connection::Connecting,
- sessions: Vec::new(),
- sessions_error: None,
+ sidebar,
chat: None,
+ copied: cx.new(|_| Copied::default()),
_tasks: Vec::new(),
};
this.connect(cx);
@@ -77,6 +86,8 @@ impl Workspace {
let Ok(notifications) = this.update(cx, |this, cx| match result {
Ok((client, notifications)) => {
this.connection = Connection::Ready(client);
+ this.sidebar
+ .update(cx, |sidebar, cx| sidebar.set_can_create(true, cx));
this.load_sessions(cx);
cx.notify();
Some(notifications)
@@ -101,6 +112,9 @@ impl Workspace {
cx: &mut AsyncApp,
) {
while let Some(first) = notifications.recv().await {
+ // Sleep the whole frame before draining, rather than waking per
+ // chunk: racing the channel would redraw at the chunk rate.
+ cx.background_executor().timer(STREAM_FRAME).await;
let mut batch = vec![first];
while let Ok(next) = notifications.try_recv() {
batch.push(next);
@@ -108,10 +122,11 @@ impl Workspace {
if this.update(cx, |this, cx| this.apply(batch, cx)).is_err() {
return;
}
- cx.background_executor().timer(STREAM_FRAME).await;
}
let _ = this.update(cx, |this, cx| {
this.connection = Connection::Failed("Host connection closed".into());
+ this.sidebar
+ .update(cx, |sidebar, cx| sidebar.set_can_create(false, cx));
cx.notify();
});
}
@@ -153,23 +168,19 @@ impl Workspace {
.session_catalog(SessionCatalogQueryInput::ListStart)
.await
});
+ let sidebar = self.sidebar.clone();
cx.spawn(async move |this, cx| {
let result = loading.await;
- let _ = this.update(cx, |this, cx| {
- match result {
- Ok(Ok(SessionCatalogQueryResult::Page { sessions, .. })) => {
- this.sessions = sessions
- .into_iter()
- .filter(|session| !session.is_archived)
- .collect();
- this.sessions_error = None;
- }
- Ok(Ok(_)) => {}
- Ok(Err(error)) => this.sessions_error = Some(error.to_string().into()),
- Err(error) => this.sessions_error = Some(error.into()),
+ sidebar.update(cx, |sidebar, cx| match result {
+ Ok(Ok(SessionCatalogQueryResult::Page { sessions, .. })) => {
+ sidebar.set_sessions(sessions, cx)
}
- cx.notify();
+ Ok(Ok(_)) => {}
+ Ok(Err(error)) => sidebar.set_error(Some(error.to_string().into()), cx),
+ Err(error) => sidebar.set_error(Some(error.into()), cx),
});
+ // The header shows the session's name.
+ let _ = this.update(cx, |_, cx| cx.notify());
})
.detach();
}
@@ -197,13 +208,13 @@ impl Workspace {
let _ = this.update_in(cx, |this, window, cx| match result {
Ok(session) => {
let id = session.id.clone();
- this.sessions.insert(0, session);
+ this.sidebar
+ .update(cx, |sidebar, cx| sidebar.insert(session, cx));
this.open(id, window, cx);
}
- Err(error) => {
- this.sessions_error = Some(error.into());
- cx.notify();
- }
+ Err(error) => this
+ .sidebar
+ .update(cx, |sidebar, cx| sidebar.set_error(Some(error.into()), cx)),
});
})
.detach();
@@ -213,6 +224,8 @@ impl Workspace {
let Some(client) = self.client() else {
return;
};
+ self.sidebar
+ .update(cx, |sidebar, cx| sidebar.select(Some(session.clone()), cx));
if self
.chat
.as_ref()
@@ -223,100 +236,144 @@ impl Workspace {
if let Some(chat) = self.chat.take() {
chat.update(cx, |chat, cx| chat.close(cx));
}
- self.chat = Some(cx.new(|cx| Chat::new(client, session, window, cx)));
+ let copied = self.copied.clone();
+ let chat = cx.new(|cx| Chat::new(client, session, copied, window, cx));
+ chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
+ self.chat = Some(chat);
cx.notify();
}
- fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
- let selected = self
+ fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ let theme = theme(cx);
+ let title = self
.chat
.as_ref()
- .map(|chat| chat.read(cx).session().to_owned());
- let items = self.sessions.iter().map(|session| {
- let id = session.id.clone();
- let label = if session.name.is_empty() {
- "未命名会话".to_string()
- } else {
- session.name.clone()
- };
- SidebarMenuItem::new(label)
- .active(selected.as_deref() == Some(id.as_str()))
- .on_click(cx.listener(move |this, _, window, cx| {
- this.open(id.clone(), window, cx);
- }))
- });
- Sidebar::new("sessions")
- .w(px(260.))
- .header(
- SidebarHeader::new().child(
- h_flex()
- .w_full()
- .justify_between()
- .child(div().font_semibold().child("Maka"))
- .child(
- Button::new("new-session")
- .ghost()
- .small()
- .icon(IconName::Plus)
- .tooltip("新建会话")
- .disabled(self.client().is_none())
- .on_click(cx.listener(|this, _, window, cx| {
- this.create_session(window, cx);
- })),
- ),
- ),
+ .and_then(|chat| self.sidebar.read(cx).name(chat.read(cx).session()))
+ .unwrap_or_default();
+ drag_region()
+ .h(px(sidebar::TITLEBAR))
+ .flex_none()
+ .px(px(14.))
+ .flex()
+ .items_center()
+ .child(
+ div()
+ .min_w_0()
+ .truncate()
+ .text_size(px(13.))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(theme.text)
+ .child(title),
)
- .child(SidebarGroup::new("会话").child(SidebarMenu::new().children(items)))
}
- fn render_main(&self, cx: &mut Context<Self>) -> AnyElement {
+ fn render_main(&self, window: &Window, cx: &mut Context<Self>) -> AnyElement {
if let Some(chat) = &self.chat {
- return chat.clone().into_any_element();
+ return embed(chat, window);
}
+ let theme = theme(cx);
let message: SharedString = match &self.connection {
Connection::Connecting => "正在连接 Host…".into(),
Connection::Failed(error) => format!("无法连接 Host:{error}").into(),
- Connection::Ready(_) => match &self.sessions_error {
- Some(error) => format!("无法读取会话:{error}").into(),
- None if self.sessions.is_empty() => "还没有会话".into(),
- None => "选择一个会话".into(),
- },
+ Connection::Ready(_) => "选择或新建一个会话".into(),
};
- v_flex()
+ let reconnect = matches!(self.connection, Connection::Failed(_)).then(|| {
+ let this = cx.weak_entity();
+ ui::button(
+ "reconnect",
+ "重新连接",
+ ui::Tone::Outline,
+ false,
+ move |_, cx| {
+ let _ = this.update(cx, |this, cx| {
+ this.connect(cx);
+ cx.notify();
+ });
+ },
+ cx,
+ )
+ });
+ div()
.size_full()
+ .flex()
+ .flex_col()
.items_center()
.justify_center()
- .gap_3()
- .text_color(cx.theme().muted_foreground)
+ .gap(px(12.))
+ .text_size(px(13.))
+ .text_color(theme.muted)
.child(message)
- .when(matches!(self.connection, Connection::Failed(_)), |this| {
- this.child(
- Button::new("reconnect")
- .label("重新连接")
- .on_click(cx.listener(|this, _, _, cx| {
- this.connect(cx);
- cx.notify();
- })),
- )
- })
+ .children(reconnect)
+ .into_any_element()
+ }
+}
+
+/// Draws `view` from cache unless it changed. While assistive technology is
+/// listening it is drawn fresh: GPUI drops a cached view's accessibility
+/// nodes when it reuses the view's last frame.
+fn embed<V: Render>(view: &Entity<V>, window: &Window) -> AnyElement {
+ if window.is_a11y_active() {
+ div().size_full().child(view.clone()).into_any_element()
+ } else {
+ view.clone()
+ .cached(StyleRefinement::default().size_full())
.into_any_element()
}
}
+/// Drags the window from the first move after a press, so clicks and
+/// double-clicks on it still reach the window.
+pub fn drag_region() -> Stateful<Div> {
+ let armed = Rc::new(Cell::new(false));
+ let on_move = armed.clone();
+ div()
+ .id("drag-region")
+ .on_mouse_down(MouseButton::Left, move |event, window, _| {
+ if event.click_count >= 2 {
+ window.titlebar_double_click();
+ } else {
+ armed.set(true);
+ }
+ })
+ .on_mouse_move(move |_, window, _| {
+ if on_move.replace(false) {
+ window.start_window_move();
+ }
+ })
+}
+
impl Render for Workspace {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- h_flex()
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let theme = theme(cx);
+ div()
.size_full()
- .items_stretch()
- .bg(cx.theme().background)
- .text_color(cx.theme().foreground)
- .child(self.render_sidebar(cx))
+ .flex()
+ .bg(theme.base)
+ .text_color(theme.text)
+ .font_family(theme.ui_font.clone())
+ .child(
+ div()
+ .id("sidebar")
+ .role(Role::Navigation)
+ .aria_label("会话")
+ .w(px(sidebar::WIDTH))
+ .h_full()
+ .flex_none()
+ .child(embed(&self.sidebar, window)),
+ )
.child(
div()
+ .id("main")
+ .role(Role::Main)
.flex_1()
.min_w_0()
.h_full()
- .child(self.render_main(cx)),
+ .flex()
+ .flex_col()
+ .border_l_1()
+ .border_color(theme.border)
+ .child(self.render_header(cx))
+ .child(div().flex_1().min_h_0().child(self.render_main(window, cx))),
)
}
}
diff --git a/crates/desktop/tests/gpui_kit.rs b/crates/desktop/tests/gpui_kit.rs
deleted file mode 100644
index ce2c3aa9fe..0000000000
--- a/crates/desktop/tests/gpui_kit.rs
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-//! Behavior the client relies on from gpui-kit, checked against the version
-//! we depend on. An ignored test names a gap the client has to cover.
-
-use gpui_kit::test::TestWindowExt;
-use gpui_kit::{
- AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, TestAppContext,
- Window,
- component::{Root, button::Button, popover::Popover, text::TextViewState},
- div, px, size,
-};
-use std::{cell::RefCell, rc::Rc};
-
-fn rendered(state: &Entity<TextViewState>, cx: &mut TestAppContext) -> String {
- state.update(cx, |state, cx| state.select_all(cx));
- state.read_with(cx, |state, _| state.selected_text())
-}
-
-/// Streams `source` one character at a time, letting each append parse on its
-/// own, and returns the rendered text.
-fn streamed(source: &str, cx: &mut TestAppContext) -> String {
- let state = cx.new(|cx| TextViewState::markdown("", cx));
- for ch in source.chars() {
- state.update(cx, |state, cx| state.push_str(&ch.to_string(), cx));
- cx.run_until_parked();
- }
- rendered(&state, cx)
-}
-
-fn parsed(source: &str, cx: &mut TestAppContext) -> String {
- let state = cx.new(|cx| TextViewState::markdown(source, cx));
- cx.run_until_parked();
- rendered(&state, cx)
-}
-
-#[gpui_kit::test]
-fn a_streamed_table_renders_like_a_full_parse(cx: &mut TestAppContext) {
- cx.update(gpui_kit::init);
- let source = "Intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter the table.\n";
- let full = parsed(source, cx);
- assert!(full.contains('3') && !full.contains('|'), "{full:?}");
- assert_eq!(streamed(source, cx), full);
-}
-
-#[gpui_kit::test]
-fn a_streamed_list_and_fence_render_like_a_full_parse(cx: &mut TestAppContext) {
- cx.update(gpui_kit::init);
- let source = "Steps:\n\n1. one\n2. two\n\n```rust\nfn main() {}\n```\n\nDone **now**.\n";
- assert_eq!(streamed(source, cx), parsed(source, cx));
-}
-
-#[gpui_kit::test]
-#[ignore = "gpui-kit 0.6.6 shows unclosed markers literally while streaming"]
-fn an_unclosed_emphasis_does_not_show_its_marker_while_streaming(cx: &mut TestAppContext) {
- cx.update(gpui_kit::init);
- let state = cx.new(|cx| TextViewState::markdown("", cx));
- state.update(cx, |state, cx| state.push_str("Hello **bol", cx));
- cx.run_until_parked();
- assert_eq!(rendered(&state, cx), "Hello bol\n");
-}
-
-struct PopoverHost {
- changes: Rc<RefCell<Vec<bool>>>,
-}
-
-impl Render for PopoverHost {
- fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
- let changes = self.changes.clone();
- div().size_full().child(
- Popover::new("popover")
- .trigger(Button::new("trigger").label("Open"))
- .content(|_, _, _| div().size(px(80.)).child("Inside"))
- .on_open_change(move |open, _, _| changes.borrow_mut().push(*open)),
- )
- }
-}
-
-fn open_popover(cx: &mut TestAppContext) -> (gpui_kit::AnyWindowHandle, Rc<RefCell<Vec<bool>>>) {
- cx.update(gpui_kit::init);
- let changes = Rc::new(RefCell::new(Vec::new()));
- let handle = cx.open_window(size(px(400.), px(300.)), {
- let changes = changes.clone();
- move |window, cx| {
- let view = cx.new(|_| PopoverHost { changes });
- Root::new(view, window, cx)
- }
- });
- let handle = handle.into();
- step(handle, cx, |window, cx| window.click("trigger", cx));
- assert_eq!(&*changes.borrow(), &[true]);
- (handle, changes)
-}
-
-fn step(
- handle: gpui_kit::AnyWindowHandle,
- cx: &mut TestAppContext,
- action: impl FnOnce(&mut Window, &mut gpui_kit::App),
-) {
- cx.update_window(handle, |_, window, cx| {
- window.render_frame(cx);
- action(window, cx);
- })
- .unwrap();
- cx.run_until_parked();
-}
-
-#[gpui_kit::test]
-fn clicking_the_trigger_of_an_open_popover_closes_it(cx: &mut TestAppContext) {
- let (handle, changes) = open_popover(cx);
- step(handle, cx, |window, cx| window.click("trigger", cx));
- assert_eq!(&*changes.borrow(), &[true, false]);
-}
-
-#[gpui_kit::test]
-fn an_opened_popover_takes_focus_so_escape_closes_it(cx: &mut TestAppContext) {
- let (handle, changes) = open_popover(cx);
- step(handle, cx, |window, cx| window.press("escape", cx));
- assert_eq!(&*changes.borrow(), &[true, false]);
-}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org