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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/visualizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,27 @@ live stream, committed scrollback chunks, and finalized history cells all
draw the same rows through the same renderer. That renderer is cached by
width and theme like any other assistant message.

## macOS app

The macOS app (`macos/`) renders the same schema with the same limits and
fallback reasons. `RustCodeKit/Visualization.swift` parses and bounds the
payload, and `RustCode/VisualizationView.swift` draws it in SwiftUI:

- `bar_chart` uses Swift Charts. Hovering a bar highlights it and shows its
exact value.
- `table` headers sort it: click once for ascending, again for descending.
Every row is kept so a sort covers the whole table, but at most 50 are
shown. `sort_by` sets the initial order.
- `tree` branches collapse and expand. The first two levels start open.

The card's Copy button and context menu copy the source fence. A block that
is still streaming shows a "Building visualization…" placeholder. Invalid or
unfinished blocks show the source with the same note as the TUI. Only
assistant replies render widgets: command output, user messages and tool
output keep the fence as code. The app advertises
`clientCapabilities._meta.rustcode.visualizations = 1` in its ACP
`initialize`.

## Assistant-only

Only the assistant message renderer recognizes the fence. User messages,
Expand All @@ -141,6 +162,7 @@ prompt and its cached prefix do not change during a session. The default is
| Frontend | Hint | How it is decided |
| --------------------------------- | ---- | --------------------------------------------------- |
| TUI (local) | yes | Set at startup |
| macOS app | yes | Advertises the ACP capability below |
| ACP client advertising support | yes | `clientCapabilities._meta.rustcode.visualizations >= 1` at `initialize` |
| Any other ACP client | no | Capability absent, malformed, or `0` |
| Remote gateway, iOS, Rust desktop, remote TUI | no | Default |
Expand Down
8 changes: 7 additions & 1 deletion macos/Sources/RustCode/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,8 @@ private struct ChatView: View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
ForEach(model.transcript.items) { item in
TranscriptRow(item: item, isCurrentMatch: model.showsFind && item.id == model.currentMatch).id(item.id)
TranscriptRow(item: item, isCurrentMatch: model.showsFind && item.id == model.currentMatch,
isStreaming: isReplyStreaming && item.id == model.transcript.items.last?.id).id(item.id)
}
if model.busy, model.turnSession == model.selectedSession, !isAgentWriting {
WorkingIndicator()
Expand Down Expand Up @@ -701,6 +702,11 @@ private struct ChatView: View {

private let bottomAnchor = "bottom"

/// The shown chat has a turn in progress, so its last reply may still grow.
private var isReplyStreaming: Bool {
model.busy && model.turnSession == model.selectedSession
}

/// Visible reply text is streaming. Whitespace or a `<think>` section the
/// model sends before its first words still counts as waiting, so
/// "Working" stays up until then.
Expand Down
8 changes: 7 additions & 1 deletion macos/Sources/RustCode/MarkdownText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import SwiftUI
/// them; code blocks, tables and rules stand on their own.
struct MarkdownText: View {
let text: String
/// Draw `rustcode-viz` blocks as widgets. Only assistant replies set it.
var visualizations = false
/// The reply is still streaming, so an unclosed visualization shows a placeholder.
var isStreaming = false
@Environment(\.findQuery) private var findQuery

private enum Segment {
Expand All @@ -16,7 +20,7 @@ struct MarkdownText: View {

private var segments: [Segment] {
var segments: [Segment] = []
for block in MarkdownBlock.parse(text) {
for block in MarkdownBlock.parse(text, visualizations: visualizations) {
switch block {
case .paragraph, .heading, .listItem, .quote:
if case .prose(let blocks) = segments.last {
Expand Down Expand Up @@ -111,6 +115,8 @@ struct MarkdownText: View {
Divider().padding(.vertical, 4)
case .image(let alt, let url):
InlineImage(url: url, alt: alt)
case .visualization(let visualization):
VisualizationView(block: visualization, isStreaming: isStreaming)
default:
EmptyView()
}
Expand Down
7 changes: 5 additions & 2 deletions macos/Sources/RustCode/TranscriptRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import SwiftUI
struct TranscriptRow: View {
let item: TranscriptItem
var isCurrentMatch = false
/// This is the reply the agent is still writing.
var isStreaming = false

var body: some View {
content
Expand All @@ -22,7 +24,7 @@ struct TranscriptRow: View {
private var content: some View {
switch item.content {
case .user(let text): UserMessage(text: text)
case .assistant(let text): AssistantMessage(text: text)
case .assistant(let text): AssistantMessage(text: text, isStreaming: isStreaming)
case .thinking(let text): ThinkingBlock(text: text)
case .tool(let tool): ToolRow(tool: tool)
case .command(let input, let output): CommandCard(input: input, output: output)
Expand Down Expand Up @@ -171,14 +173,15 @@ private struct UserBubble: View {
/// Agent text, with `<think>` sections the engine leaves inline folded away.
private struct AssistantMessage: View {
let text: String
var isStreaming = false
@Environment(\.messageActions) private var messageActions

var body: some View {
VStack(alignment: .leading, spacing: 10) {
let (prose, summary) = AssistantSummary.split(text)
ForEach(Array(AssistantBlock.parse(prose).enumerated()), id: \.offset) { _, block in
switch block {
case .markdown(let text): MarkdownText(text: text)
case .markdown(let text): MarkdownText(text: text, visualizations: true, isStreaming: isStreaming)
case .thinking(let text, _): ThinkingBlock(text: text)
}
}
Expand Down
Loading
Loading