Refine assistant transcript behavior

This commit is contained in:
Kaede Akino committed 2026-08-26 18:10:51 +08:00
1 parent c59e2cb9e5
commit 70c66d8cd0
8 files changed
+242 -52

No files matched your search

+13 -4
View File
@@ -197,7 +197,11 @@ final class AppModel {
}
}
func sendAgentDraft(_ conversation: ConversationModel, context: SessionContext = .none) {
func sendAgentDraft(
_ conversation: ConversationModel,
context: SessionContext = .none,
includesTerminalSuggestion: Bool = false
) {
let draft = conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !draft.isEmpty else { return }
// The *returned* text, not `draft`: attachments are folded in there, and
@@ -205,13 +209,17 @@ final class AppModel {
// not match the one the conversation recorded.
let prompt = conversation.appendUserTurn(draft)
conversation.draft = ""
startAgentRun(conversation, prompt: prompt, context: context)
startAgentRun(
conversation, prompt: prompt, context: context,
includesTerminalSuggestion: includesTerminalSuggestion
)
}
func startAgentRun(
_ conversation: ConversationModel,
prompt: String? = nil,
context: SessionContext = .none
context: SessionContext = .none,
includesTerminalSuggestion: Bool = false
) {
let userPrompt = prompt
?? conversation.messages.last(where: { $0.role == .user })?.text
@@ -288,7 +296,8 @@ final class AppModel {
providers.append(AgentStaticToolProvider(AgentBuiltInTools.make(
hosts: agentHosts, connections: agentConnections,
authentication: agentAuthentication,
background: agentBackgroundCommands, search: searchPlan.local
background: agentBackgroundCommands, search: searchPlan.local,
includesTerminalSuggestion: includesTerminalSuggestion
)))
providers.append(AgentStaticToolProvider(AgentScratchTools.make(
workspace: scratch, connections: agentConnections,
@@ -13,13 +13,16 @@ nonisolated enum AgentBuiltInTools: AgentToolSchemaBuilding {
authentication suppliedAuthentication: AgentSSHAuthenticationRegistry? = nil,
background: AgentBackgroundCommands = AgentBackgroundCommands(),
fetcher: RestrictedHTTPFetcher = RestrictedHTTPFetcher(),
search: WebSearchClient? = nil
search: WebSearchClient? = nil,
includesTerminalSuggestion: Bool = false
) -> [AnyAgentTool] {
let authentication = suppliedAuthentication
?? AgentSSHAuthenticationRegistry(resolver: hosts)
let terminalTools = includesTerminalSuggestion ? [terminalSuggest()] : []
return [
listHosts(hosts), sshExec(authentication, connections, background),
sshWait(background), terminalSuggest(),
sshWait(background),
] + terminalTools + [
sftpList(authentication, connections), sftpStat(authentication, connections),
sftpRead(authentication, connections), sftpWrite(authentication, connections),
sftpMkdir(authentication, connections), sftpMove(authentication, connections),
+7 -4
View File
@@ -49,6 +49,7 @@ struct RiskBadge: View {
/// ```
struct CommandCardView: View {
let command: SuggestedCommand
var showsTerminalActions = false
@Environment(WindowModel.self) private var window
@State private var isConfirming = false
@@ -91,10 +92,12 @@ struct CommandCardView: View {
}
HStack(spacing: Metrics.Space.s) {
Button("Insert") { insert(run: false) }
Button(runTitle) { run() }
.buttonStyle(.borderedProminent)
.tint(assessment.risk == .destructive ? .red : .accentColor)
if showsTerminalActions {
Button("Insert") { insert(run: false) }
Button(runTitle) { run() }
.buttonStyle(.borderedProminent)
.tint(assessment.risk == .destructive ? .red : .accentColor)
}
Spacer()
Button {
copyToPasteboard(command.command)
@@ -5,11 +5,26 @@ import SwiftUI
/// Shared by the session Assistant sidebar and full Assistant tab so the two can never drift; only
/// the padding and the provider header differ.
struct ConversationView: View {
private enum ScrollTarget: Hashable { case bottom }
private struct TranscriptScrollMetrics: Equatable {
let isAtBottom: Bool
let contentHeight: CGFloat
}
let conversation: ConversationModel
var isCompact: Bool = false
/// Forwarded to the composer. Only the sidebar has a focused pane to
/// describe, so only the sidebar passes one.
var sessionContext: SessionContext = .none
/// A conversation can be shown on both surfaces. Terminal actions belong
/// to the Workspace sidebar displaying it, not to the conversation itself.
var showsTerminalActions = false
@State private var isAtTranscriptBottom = true
@State private var isFollowingTranscriptBottom = true
@State private var isUserScrollingTranscript = false
@State private var isTranscriptScrollPending = true
var body: some View {
VStack(spacing: 0) {
@@ -44,7 +59,8 @@ struct ConversationView: View {
ComposerView(
conversation: conversation,
maxContentWidth: isCompact ? nil : Metrics.Chat.contentMaxWidth,
sessionContext: sessionContext
sessionContext: sessionContext,
includesTerminalSuggestion: showsTerminalActions
)
}
}
@@ -70,6 +86,9 @@ struct ConversationView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Color.clear
.frame(height: 1)
.id(ScrollTarget.bottom)
}
}
// Horizontal padding belongs to the column, not to each turn:
@@ -82,23 +101,85 @@ struct ConversationView: View {
)
.frame(maxWidth: .infinity)
}
// Keyed on the last row's revision, not on a count. Streaming text
// grows one message without adding a row, so a count never changed
// and the transcript stopped following the reply it was showing.
.onChange(of: conversation.timeline.last?.revision) {
guard let last = conversation.timeline.last else { return }
withAnimation(Motion.tabSwitch) { proxy.scrollTo(last.id, anchor: .bottom) }
.onScrollGeometryChange(for: TranscriptScrollMetrics.self) { geometry in
TranscriptScrollMetrics(
isAtBottom: geometry.visibleRect.maxY
>= geometry.contentSize.height - Metrics.Space.l,
contentHeight: geometry.contentSize.height
)
} action: { oldMetrics, newMetrics in
isAtTranscriptBottom = newMetrics.isAtBottom
if isUserScrollingTranscript {
isFollowingTranscriptBottom = newMetrics.isAtBottom
}
// A token only needs a scroll when it actually wraps or adds a
// block. Watching text revisions scheduled work for every token
// even though most of them did not move the bottom at all.
if oldMetrics.contentHeight != newMetrics.contentHeight {
requestScrollToBottom()
}
}
.onScrollPhaseChange { _, phase in
switch phase {
case .tracking, .interacting, .decelerating:
isUserScrollingTranscript = true
if !isAtTranscriptBottom {
isFollowingTranscriptBottom = false
}
case .idle:
if isUserScrollingTranscript {
isFollowingTranscriptBottom = isAtTranscriptBottom
}
isUserScrollingTranscript = false
case .animating:
// Programmatic scrolling is not an instruction to stop
// following. Only direct user interaction changes it.
break
}
}
// Sending a new prompt is an explicit return to the live edge,
// even if the reader had previously scrolled up through history.
.onChange(of: conversation.messages.last(where: { $0.role == .user })?.id) {
requestScrollToBottom(force: true)
}
.task(id: isTranscriptScrollPending) {
// One view-owned debounce coalesces rapid layout changes.
// Unlike an unstructured Task per token, it is cancelled with
// the Sidebar and can never retain a stale ScrollViewProxy.
guard isTranscriptScrollPending else { return }
do {
try await Task.sleep(for: .milliseconds(80))
} catch {
return
}
guard isFollowingTranscriptBottom else {
isTranscriptScrollPending = false
return
}
proxy.scrollTo(ScrollTarget.bottom, anchor: .bottom)
isTranscriptScrollPending = false
}
}
}
private func requestScrollToBottom(force: Bool = false) {
guard force || isFollowingTranscriptBottom else { return }
if force { isFollowingTranscriptBottom = true }
isTranscriptScrollPending = true
}
@ViewBuilder
private func row(for item: TimelineItem) -> some View {
switch item {
case .message(let message) where message.role == .user:
UserMessageView(message: message, isCompact: isCompact)
case .message(let message):
AssistantMessageView(message: message, isCompact: isCompact)
AssistantMessageView(
message: message, isCompact: isCompact,
showsTerminalActions: showsTerminalActions
)
case .tool(let card):
AgentToolRowView(card: card)
}
@@ -148,6 +229,8 @@ private struct AgentToolRowView: View {
.font(MarkdownTypography.body)
.lineLimit(isExpanded ? nil : 1)
.truncationMode(.tail)
.foregroundStyle(card.state == .running ? .secondary : .primary)
.modifier(ScanningTextMask(isActive: card.state == .running))
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: Metrics.Space.s)
if card.state == .reviewing {
+6 -1
View File
@@ -127,6 +127,8 @@ struct ComposerView: View {
/// Defaulting to `.none` is what makes a composer outside a Workspace — the
/// Assistant tab — session-free by construction rather than by remembering.
var sessionContext: SessionContext = .none
/// Only the Workspace sidebar gives its agent a command-card tool.
var includesTerminalSuggestion = false
var onSend: (() -> Void)?
@Environment(AppModel.self) private var app
@@ -523,7 +525,10 @@ struct ComposerView: View {
if let onSend {
onSend()
} else {
app.sendAgentDraft(conversation, context: sessionContext)
app.sendAgentDraft(
conversation, context: sessionContext,
includesTerminalSuggestion: includesTerminalSuggestion
)
}
}
+85 -31
View File
@@ -53,6 +53,7 @@ struct UserMessageView: View {
struct AssistantMessageView: View {
let message: ChatMessage
var isCompact = false
var showsTerminalActions = false
@State private var isHovering = false
@@ -78,7 +79,9 @@ struct AssistantMessageView: View {
}
ForEach(message.commands) { command in
CommandCardView(command: command)
CommandCardView(
command: command, showsTerminalActions: showsTerminalActions
)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -89,46 +92,97 @@ struct AssistantMessageView: View {
}
}
/// A compact waveform rather than a generic spinner. Four offset bars make
/// progress feel alive without implying a measurable percentage, while the
/// label says plainly why the conversation is waiting.
/// A quiet, text-only activity indicator. The wording changes occasionally so
/// a long wait does not feel frozen, while the transparent scan supplies the
/// continuous motion shared by running tool titles.
private struct WorkingIndicator: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var statusIndex = 0
private static let statuses = [
"Working…",
"Thinking…",
"Reasoning…",
]
var body: some View {
HStack(spacing: Metrics.Space.m) {
if reduceMotion {
bars(phase: 0)
} else {
TimelineView(.animation) { timeline in
bars(phase: timeline.date.timeIntervalSinceReferenceDate * 5)
Text(Self.statuses[statusIndex])
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
.modifier(ScanningTextMask(isActive: true))
.padding(.vertical, Metrics.Space.xs)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(Self.statuses[statusIndex]))
.task(id: reduceMotion) {
if reduceMotion {
statusIndex = 0
return
}
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2.8))
guard !Task.isCancelled else { return }
// Pick any other phrase, so a timer tick always produces a
// visible change instead of randomly choosing the same item.
let offset = Int.random(in: 1..<Self.statuses.count)
statusIndex = (statusIndex + offset) % Self.statuses.count
}
}
Text("Working…")
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.m)
.background(
.quaternary.opacity(0.36),
in: .capsule
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("Working"))
}
}
private func bars(phase: TimeInterval) -> some View {
HStack(alignment: .center, spacing: 3) {
ForEach(0..<4, id: \.self) { index in
let pulse = (sin(phase - Double(index) * 0.85) + 1) / 2
Capsule()
.fill(Color.accentColor.opacity(0.48 + pulse * 0.5))
.frame(width: 4, height: 6 + pulse * 12)
/// Cuts a soft transparent notch out of active text and moves it from leading
/// to trailing, matching the familiar "Thinking" text treatment. The text is
/// the only moving surface; surrounding controls stay still.
struct ScanningTextMask: ViewModifier {
let isActive: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
if isActive, !reduceMotion {
content.mask {
GeometryReader { geometry in
TimelineView(.animation) { timeline in
let duration = 1.55
let progress = timeline.date.timeIntervalSinceReferenceDate
.truncatingRemainder(dividingBy: duration) / duration
// The reference treatment loses roughly one third of
// the word at a time: a fully transparent center with
// long, soft shoulders rather than a narrow glint.
let bandWidth = min(max(geometry.size.width * 0.32, 40), 96)
ZStack(alignment: .leading) {
Rectangle().fill(.white)
LinearGradient(
colors: [
.clear,
.white.opacity(0.34),
.white.opacity(0.82),
.white,
.white.opacity(0.82),
.white.opacity(0.34),
.clear,
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: bandWidth)
.offset(
x: -bandWidth
+ (geometry.size.width + bandWidth) * progress
)
.blendMode(.destinationOut)
}
.compositingGroup()
}
}
}
} else {
content
}
.frame(width: 25, height: 20)
}
}
@@ -79,10 +79,12 @@ struct AssistantPanelView: View {
conversation: window.sidebar.assistantDraft,
prompt: "Ask anything…",
sessionContext: window.sidebar.sessionContext,
includesTerminalSuggestion: true,
onSend: {
if let conversation = window.sendAssistantSidebarDraft() {
app.startAgentRun(
conversation, context: window.sidebar.sessionContext
conversation, context: window.sidebar.sessionContext,
includesTerminalSuggestion: true
)
}
}
@@ -157,7 +159,8 @@ struct AssistantPanelView: View {
Divider()
ConversationView(
conversation: conversation, isCompact: true,
sessionContext: window.sidebar.sessionContext
sessionContext: window.sidebar.sessionContext,
showsTerminalActions: true
)
}
}
@@ -0,0 +1,30 @@
import Testing
@testable import Termind
@Suite
@MainActor
struct AgentBuiltInToolAvailabilityTests {
@Test
func terminalSuggestionIsRegisteredOnlyWhenTheSurfaceOptsIn() {
let hosts = AgentHostResolver(store: HostLibraryModel())
let connections = AgentConnectionPool(
configuration: { _ in throw AgentToolError.invalidHost },
routeFingerprint: { _ in "" },
openExec: { _ in throw AgentToolError.invalidHost },
openSFTP: { _ in throw AgentToolError.invalidHost },
sweepInterval: nil
)
let mainAssistant = AgentBuiltInTools.make(
hosts: hosts, connections: connections
)
let workspaceSidebar = AgentBuiltInTools.make(
hosts: hosts, connections: connections,
includesTerminalSuggestion: true
)
#expect(!mainAssistant.contains { $0.descriptor.name == "terminal_suggest" })
#expect(workspaceSidebar.contains { $0.descriptor.name == "terminal_suggest" })
}
}