From 70c66d8cd0372d1acc2cf8cd1035b899c1d1aced Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 26 Aug 2026 18:10:51 +0800 Subject: [PATCH] Refine assistant transcript behavior --- Termind/App/AppModel.swift | 17 ++- .../Application/Agent/AgentBuiltInTools.swift | 7 +- Termind/Features/AI/CommandCardView.swift | 11 +- .../AI/ConversationTranscriptView.swift | 99 +++++++++++++-- Termind/Features/AI/ConversationViews.swift | 7 +- Termind/Features/AI/MessageViews.swift | 116 +++++++++++++----- .../Features/Assistant/AssistantView.swift | 7 +- .../AgentBuiltInToolAvailabilityTests.swift | 30 +++++ 8 files changed, 242 insertions(+), 52 deletions(-) create mode 100644 TermindTests/AgentBuiltInToolAvailabilityTests.swift diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index b5b988d7..41ec4943 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -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, diff --git a/Termind/Application/Agent/AgentBuiltInTools.swift b/Termind/Application/Agent/AgentBuiltInTools.swift index 00a4f819..74296d63 100644 --- a/Termind/Application/Agent/AgentBuiltInTools.swift +++ b/Termind/Application/Agent/AgentBuiltInTools.swift @@ -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), diff --git a/Termind/Features/AI/CommandCardView.swift b/Termind/Features/AI/CommandCardView.swift index 9e42556c..4fe5ceb3 100644 --- a/Termind/Features/AI/CommandCardView.swift +++ b/Termind/Features/AI/CommandCardView.swift @@ -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) diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 548f241c..a2905ecd 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -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 { diff --git a/Termind/Features/AI/ConversationViews.swift b/Termind/Features/AI/ConversationViews.swift index 63ede985..823bb514 100644 --- a/Termind/Features/AI/ConversationViews.swift +++ b/Termind/Features/AI/ConversationViews.swift @@ -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 + ) } } diff --git a/Termind/Features/AI/MessageViews.swift b/Termind/Features/AI/MessageViews.swift index 427d39dd..9fb94585 100644 --- a/Termind/Features/AI/MessageViews.swift +++ b/Termind/Features/AI/MessageViews.swift @@ -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.. 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) } } diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index 4681aadd..2af336bd 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -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 ) } } diff --git a/TermindTests/AgentBuiltInToolAvailabilityTests.swift b/TermindTests/AgentBuiltInToolAvailabilityTests.swift new file mode 100644 index 00000000..f516dbbd --- /dev/null +++ b/TermindTests/AgentBuiltInToolAvailabilityTests.swift @@ -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" }) + } +}