166 lines
5.9 KiB
Swift
166 lines
5.9 KiB
Swift
import SwiftUI
|
|
import TermindDomain
|
|
|
|
/// The one fenced block Termind owns instead of MarkdownView: a shell command
|
|
/// authored by the assistant and therefore eligible for local risk assessment.
|
|
struct AssistantShellCodeBlockView: View {
|
|
let language: String
|
|
let command: String
|
|
|
|
var body: some View {
|
|
ShellCodeBlockView(language: language, command: command)
|
|
}
|
|
}
|
|
|
|
// MARK: - Shell
|
|
|
|
private struct ShellCodeBlockView: View {
|
|
let language: String
|
|
let command: String
|
|
|
|
@Environment(WindowModel.self) private var window
|
|
@State private var isConfirming = false
|
|
|
|
private var assessment: RiskAssessment { CommandRiskClassifier.assess(command) }
|
|
|
|
/// Whether this fence may be delivered to a prompt at all.
|
|
///
|
|
/// `SSHSessionModel.insert` writes raw bytes with no bracketed-paste
|
|
/// wrapper, so an embedded newline reaches the PTY as Return. "Insert" on a
|
|
/// three-line script would execute the first two lines while presenting
|
|
/// itself as a preview — so multi-line fences get the badge and a copy
|
|
/// button and nothing that touches the session.
|
|
private var isSingleLine: Bool { !command.contains("\n") }
|
|
|
|
/// The shell this card would reach.
|
|
///
|
|
/// `selectedWorkspace` is `nil` whenever the pinned Assistant destination is showing, and
|
|
/// that is exactly where the full-width transcript lives — so the buttons
|
|
/// disable there instead of looking live and doing nothing.
|
|
private var target: SSHSessionModel? {
|
|
guard let workspace = window.selectedWorkspace else { return nil }
|
|
return workspace.focusedPane.sshSession ?? workspace.broadcastTargets.first
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.m) {
|
|
HStack(spacing: Metrics.Space.xs) {
|
|
Text(language)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: Metrics.Space.s)
|
|
RiskBadge(assessment: assessment)
|
|
}
|
|
|
|
Text(command)
|
|
.font(MarkdownTypography.monospaced)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
actions
|
|
}
|
|
.padding(Metrics.Chat.codeInset)
|
|
.background(
|
|
Color(nsColor: .controlBackgroundColor),
|
|
in: .rect(cornerRadius: Metrics.Radius.large)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Metrics.Radius.large)
|
|
.strokeBorder(
|
|
assessment.risk == .destructive
|
|
? TermindColor.danger.opacity(0.4) : Color.secondary.opacity(0.2)
|
|
)
|
|
}
|
|
.confirmationDialog(
|
|
Text("Run a destructive command?"),
|
|
isPresented: $isConfirming,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Run \(command)", role: .destructive) { deliver(submit: true) }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text(assessment.reasons.joined(separator: "\n"))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var actions: some View {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
if isSingleLine {
|
|
Button("Insert") { deliver(submit: false) }
|
|
.disabled(target == nil)
|
|
Button("Run") { run() }
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(
|
|
assessment.risk == .destructive
|
|
? TermindColor.danger : TermindColor.accent
|
|
)
|
|
.disabled(target == nil)
|
|
} else {
|
|
Text("Several commands — copy and review before running.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: Metrics.Space.s)
|
|
CopyButton(text: command)
|
|
}
|
|
.controlSize(.small)
|
|
.help(Text(target == nil ? String(localized: "No shell in the foreground") : ""))
|
|
}
|
|
|
|
private func run() {
|
|
if assessment.requiresSecondConfirmation {
|
|
isConfirming = true
|
|
} else {
|
|
deliver(submit: true)
|
|
}
|
|
}
|
|
|
|
/// Through `Workspace.deliver`, so a card obeys the same broadcast rule as
|
|
/// every other programmatic route to a prompt. `submit: false` writes
|
|
/// without the newline; the user's own Return is what runs it.
|
|
private func deliver(submit: Bool) {
|
|
guard let workspace = window.selectedWorkspace, let origin = target else { return }
|
|
workspace.deliver(command, from: origin, submit: submit)
|
|
}
|
|
}
|
|
|
|
// MARK: - Copy
|
|
|
|
/// A copy button that confirms itself for a moment, so a click on a glyph that
|
|
/// produces no visible change still reads as having worked.
|
|
struct CopyButton: View {
|
|
let text: String
|
|
var title: LocalizedStringKey? = nil
|
|
|
|
@State private var hasCopied = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
guard copyToPasteboard(text) else { return }
|
|
hasCopied = true
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(1.4))
|
|
hasCopied = false
|
|
}
|
|
} label: {
|
|
if let title {
|
|
Label(hasCopied ? "Copied" : title, systemImage: hasCopied ? "checkmark" : "doc.on.doc")
|
|
.contentTransition(.symbolEffect(.replace))
|
|
.frame(minHeight: 28)
|
|
.contentShape(.rect)
|
|
} else {
|
|
Image(systemName: hasCopied ? "checkmark" : "doc.on.doc")
|
|
.contentTransition(.symbolEffect(.replace))
|
|
.frame(width: 28, height: 28)
|
|
.contentShape(.rect)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.help(Text(title ?? "Copy"))
|
|
.accessibilityLabel(Text(title ?? "Copy"))
|
|
}
|
|
}
|