From c376f487bfec5eddf208c8f0394ee7801df1d672 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Mon, 20 Jul 2026 19:03:55 +0900 Subject: [PATCH] Render agent runs as a main-view transcript with an auxiliary terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent prompts no longer run inside the embedded PTY. Each run launches the CLI headless (claude -p --output-format stream-json; codex exec --json with the current ThreadEvent schema plus the legacy msg dialect) and streams JSONL into transcript items — assistant messages, tool/code-change rows, and error notices — rendered in the main conversation view and persisted per thread. Run completion is simply process exit, replacing the PTY foreground-group heuristics, and deleting a thread or closing the workspace cancels its run so no subprocess outlives its UI. The terminal becomes a VS-Code-style auxiliary panel: submitting never opens it, its divider drag-resizes it (dragging below the close threshold or press-and-holding closes it), and the send button turns into a stop control while a run streams. The conversation auto-scrolls with streaming output, shows an animated thinking/working indicator, and new threads default to ~/Desktop instead of running without a working directory. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015peMZyaHEmxDzyzNyhYeR5 --- .../MikuCodeApp/Agent/AgentEventStream.swift | 133 ++++++ .../MikuCodeApp/Agent/AgentTranscript.swift | 61 +++ .../Agent/AgentWorkspaceModel.swift | 245 +++++------ .../Agent/HeadlessAgentRunner.swift | 150 +++++++ .../Agent/WorkspaceSessionStore.swift | 105 ++++- .../Agent/WorkspaceSessionsRepository.swift | 31 ++ Sources/MikuCodeApp/AgentWorkspaceView.swift | 405 +++++++++++++++-- .../AgentEventStreamTests.swift | 138 ++++++ .../AgentRunRequestTests.swift | 413 +++++++++--------- .../AgentWorkspaceLayoutTests.swift | 32 ++ .../HeadlessAgentRunnerTests.swift | 91 ++++ .../WorkspaceSessionStoreTests.swift | 137 +++++- 12 files changed, 1523 insertions(+), 418 deletions(-) create mode 100644 Sources/MikuCodeApp/Agent/AgentEventStream.swift create mode 100644 Sources/MikuCodeApp/Agent/AgentTranscript.swift create mode 100644 Sources/MikuCodeApp/Agent/HeadlessAgentRunner.swift create mode 100644 Tests/MikuCodeAppTests/AgentEventStreamTests.swift create mode 100644 Tests/MikuCodeAppTests/AgentWorkspaceLayoutTests.swift create mode 100644 Tests/MikuCodeAppTests/HeadlessAgentRunnerTests.swift diff --git a/Sources/MikuCodeApp/Agent/AgentEventStream.swift b/Sources/MikuCodeApp/Agent/AgentEventStream.swift new file mode 100644 index 0000000..aa53334 --- /dev/null +++ b/Sources/MikuCodeApp/Agent/AgentEventStream.swift @@ -0,0 +1,133 @@ +import Foundation + +/// Normalized event decoded from one JSONL line of a headless agent run. +enum AgentStreamEvent: Equatable { + case assistantText(String) + case toolUse(name: String, detail: String?) + case completed(isError: Bool, summary: String?) +} + +/// Tolerant decoder for the structured output dialects the app launches: +/// `claude -p --output-format stream-json` (top-level `type`: assistant/result), +/// `codex exec --json` ThreadEvent lines (top-level `type`: item.*/turn.*), and +/// the legacy codex `msg.type` wrapper older CLIs emitted. Unknown or non-JSON +/// lines decode to no events so interleaved diagnostics never break a run. +struct AgentEventStreamParser { + func events(fromLine line: String) -> [AgentStreamEvent] { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("{"), + let object = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)), + let dictionary = object as? [String: Any] else { + return [] + } + if let message = dictionary["msg"] as? [String: Any], + let type = message["type"] as? String { + return legacyCodexEvents(type: type, from: message) + } + guard let type = dictionary["type"] as? String else { return [] } + if type.hasPrefix("item.") || type.hasPrefix("turn.") || type == "error" { + return codexThreadEvents(type: type, from: dictionary) + } + return claudeEvents(type: type, from: dictionary) + } + + private func claudeEvents(type: String, from dictionary: [String: Any]) -> [AgentStreamEvent] { + switch type { + case "assistant": + guard let message = dictionary["message"] as? [String: Any], + let content = message["content"] as? [[String: Any]] else { + return [] + } + return content.compactMap { block in + switch block["type"] as? String { + case "text": + guard let text = block["text"] as? String, !text.isEmpty else { return nil } + return .assistantText(text) + case "tool_use": + return .toolUse( + name: block["name"] as? String ?? "tool", + detail: Self.toolDetail(block["input"]) + ) + default: + return nil + } + } + case "result": + return [.completed( + isError: dictionary["is_error"] as? Bool ?? false, + summary: dictionary["result"] as? String + )] + default: + return [] + } + } + + /// codex-cli ≥0.141 `exec --json`: ThreadEvent JSONL. Assistant text arrives + /// as completed `agent_message` items, commands as `command_execution` + /// items, patches as `file_change` items; the turn ends with + /// `turn.completed` / `turn.failed`. + private func codexThreadEvents(type: String, from dictionary: [String: Any]) -> [AgentStreamEvent] { + switch type { + case "item.started", "item.completed": + guard let item = dictionary["item"] as? [String: Any], + let itemType = item["type"] as? String else { + return [] + } + // Command/patch items render once, at start; text renders once the + // item is complete so partial deltas never split one message. + switch (type, itemType) { + case ("item.started", "command_execution"): + return [.toolUse(name: "shell", detail: item["command"] as? String)] + case ("item.started", "file_change"): + return [.toolUse(name: "apply_patch", detail: nil)] + case ("item.completed", "agent_message"): + guard let text = item["text"] as? String, !text.isEmpty else { return [] } + return [.assistantText(text)] + case ("item.completed", "error"): + return [.completed(isError: true, summary: item["message"] as? String)] + default: + return [] + } + case "turn.completed": + return [.completed(isError: false, summary: nil)] + case "turn.failed": + let error = dictionary["error"] as? [String: Any] + return [.completed(isError: true, summary: error?["message"] as? String)] + case "error": + return [.completed(isError: true, summary: dictionary["message"] as? String)] + default: + return [] + } + } + + private func legacyCodexEvents(type: String, from message: [String: Any]) -> [AgentStreamEvent] { + switch type { + case "agent_message": + guard let text = message["message"] as? String, !text.isEmpty else { return [] } + return [.assistantText(text)] + case "exec_command_begin": + let command = (message["command"] as? [String])?.joined(separator: " ") + return [.toolUse(name: "shell", detail: command)] + case "patch_apply_begin": + return [.toolUse(name: "apply_patch", detail: nil)] + case "error": + return [.completed(isError: true, summary: message["message"] as? String)] + case "task_complete": + return [.completed(isError: false, summary: message["last_agent_message"] as? String)] + default: + return [] + } + } + + /// The single most descriptive input field, shown next to the tool name + /// (file path for edits, command for shell, pattern for searches). + private static func toolDetail(_ input: Any?) -> String? { + guard let dictionary = input as? [String: Any] else { return nil } + for key in ["file_path", "command", "pattern", "path", "url", "description"] { + if let value = dictionary[key] as? String, !value.isEmpty { + return value + } + } + return nil + } +} diff --git a/Sources/MikuCodeApp/Agent/AgentTranscript.swift b/Sources/MikuCodeApp/Agent/AgentTranscript.swift new file mode 100644 index 0000000..20eca52 --- /dev/null +++ b/Sources/MikuCodeApp/Agent/AgentTranscript.swift @@ -0,0 +1,61 @@ +import Foundation + +/// One rendered row of a thread's conversation: the user's prompt, a streamed +/// assistant message, a tool/code-change event, or a run-level notice. +enum AgentTranscriptItem: Identifiable { + case prompt(AgentPrompt) + case assistant(AgentAssistantMessage) + case tool(AgentToolEvent) + case notice(AgentRunNotice) + + var id: UUID { + switch self { + case let .prompt(prompt): prompt.id + case let .assistant(message): message.id + case let .tool(event): event.id + case let .notice(notice): notice.id + } + } +} + +struct AgentAssistantMessage: Identifiable { + let id: UUID + let provider: AgentProvider + var text: String + + init(id: UUID = UUID(), provider: AgentProvider, text: String) { + self.id = id + self.provider = provider + self.text = text + } +} + +struct AgentToolEvent: Identifiable { + let id: UUID + let name: String + let detail: String? + + init(id: UUID = UUID(), name: String, detail: String?) { + self.id = id + self.name = name + self.detail = detail + } + + /// Tools that modify files render as code-change rows in the transcript. + var isCodeChange: Bool { + ["edit", "write", "multiedit", "notebookedit", "apply_patch"] + .contains(name.lowercased()) + } +} + +struct AgentRunNotice: Identifiable { + let id: UUID + let text: String + let isError: Bool + + init(id: UUID = UUID(), text: String, isError: Bool) { + self.id = id + self.text = text + self.isError = isError + } +} diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift index 9b4a1e0..95322c4 100644 --- a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -84,7 +84,7 @@ struct AgentRunRequest: Equatable { // Allow multi-line prompts: newline (0x0A) and tab (0x09) are the only // control characters the multi-line composer can produce and both stay // literal inside POSIX single-quote wrapping. Every other control/escape - // byte (e.g. Ctrl-C, ESC) remains rejected so it cannot reach the PTY. + // byte (e.g. Ctrl-C, ESC) remains rejected so it cannot reach the shell. guard prompt.unicodeScalars.allSatisfy({ scalar in (scalar.value >= 0x20 && scalar.value != 0x7F) || scalar.value == 0x0A @@ -97,7 +97,10 @@ struct AgentRunRequest: Equatable { self.prompt = prompt } - var shellCommand: String { + /// The non-interactive structured-output invocation for one agent turn. + /// Both CLIs stream JSONL on stdout, which the app renders as the main + /// conversation transcript; the integrated terminal is not involved. + var headlessCommand: String { switch provider { case .codex: let sandbox = switch approval { @@ -105,14 +108,17 @@ struct AgentRunRequest: Equatable { case .accept: "workspace-write" case .auto: "danger-full-access" } - return "codex exec --sandbox \(sandbox) \(Self.quote(prompt))" + // --skip-git-repo-check: threads may run in non-git folders; the + // sandbox still bounds what the run can touch. + return "codex exec --json --skip-git-repo-check --sandbox \(sandbox) \(Self.quote(prompt))" case .claude: let permissionMode = switch approval { case .plan: "plan" case .accept: "acceptEdits" case .auto: "bypassPermissions" } - return "claude --permission-mode \(permissionMode) \(Self.quote(prompt))" + return "claude -p --verbose --output-format stream-json" + + " --permission-mode \(permissionMode) \(Self.quote(prompt))" case .pi, .openCode: preconditionFailure("Unsupported provider cannot create a run request.") } @@ -123,19 +129,9 @@ struct AgentRunRequest: Equatable { } } -@MainActor -struct LocalPTYAgentRunner { - let terminalSession: TerminalSessionModel - - func start(_ request: AgentRunRequest) -> TerminalAgentInputSubmission? { - guard terminalSession.isRunning else { return nil } - return terminalSession.submitAgentCommand(request.shellCommand) - } -} - @MainActor final class AgentWorkspaceModel: ObservableObject { - typealias RequestStarter = (TerminalSessionModel, AgentRunRequest) -> TerminalAgentInputSubmission? + typealias RunnerFactory = @MainActor (AgentRunRequest, URL?) -> AgentRunProcess enum RunLifecycle: Equatable { case idle @@ -145,182 +141,139 @@ final class AgentWorkspaceModel: ObservableObject { @Published var provider = AgentProvider.codex @Published var approval = AgentApproval.accept @Published var prompt = "" - @Published private(set) var submittedPrompts: [AgentPrompt] = [] - @Published private(set) var pendingPrompt: AgentPrompt? - private(set) var pendingPromptDeliveryToken: UUID? + /// The conversation rendered in the main agent view: prompts, streamed + /// assistant messages, tool/code-change events, and run notices. + @Published private(set) var transcript: [AgentTranscriptItem] = [] + /// The auxiliary terminal panel. Only the user opens or closes it; agent + /// runs never force it open. @Published var isTerminalPresented = false @Published private(set) var submissionError: String? @Published private(set) var runLifecycle: RunLifecycle = .idle - /// Fires after a prompt lands in the transcript so the session store can - /// persist thread titles/history. + /// Fires after the transcript changes so the session store can persist + /// thread titles/history. var onTranscriptChanged: (() -> Void)? - private let startRequest: RequestStarter - private let isForegroundCommandRunning: (TerminalSessionModel) -> Bool - private weak var runTerminalSession: TerminalSessionModel? - // Guards the run-start race: right after the command is written the shell is - // still the foreground group (it has not forked the agent yet). We only treat - // a return-to-shell as completion once we have actually observed the agent - // take the foreground at least once. - private var runObservedForegroundCommand = false - // If the agent never takes the foreground within this grace period (e.g. the - // binary is missing and the shell printed "command not found"), the run is - // resolved on the next flush instead of locking the composer forever. - private let runStartupGrace: Duration - private var runStartInstant: ContinuousClock.Instant? + private let makeRunner: RunnerFactory + private var activeRun: AgentRunProcess? init( - startRequest: @escaping RequestStarter = { terminalSession, request in - LocalPTYAgentRunner(terminalSession: terminalSession).start(request) - }, - isForegroundCommandRunning: @escaping (TerminalSessionModel) -> Bool = { session in - session.isForegroundCommandRunning() - }, - runStartupGrace: Duration = .milliseconds(500) + makeRunner: @escaping RunnerFactory = { request, workingDirectory in + HeadlessAgentRunner(request: request, workingDirectory: workingDirectory) + } ) { - self.startRequest = startRequest - self.isForegroundCommandRunning = isForegroundCommandRunning - self.runStartupGrace = runStartupGrace + self.makeRunner = makeRunner + } + + /// Prompts recorded in transcript order; the first one titles the thread. + var submittedPrompts: [AgentPrompt] { + transcript.compactMap { + if case let .prompt(prompt) = $0 { return prompt } + return nil + } } - func submit(to terminalSession: TerminalSessionModel) { - // A run must finish (or its queued write must be resolved) before the next - // prompt may be submitted; otherwise prompt B is written into the busy PTY - // while agent A still owns it (swallowed by a REPL, or — with codex exec — - // run as a shell command after codex exits). - guard runLifecycle == .idle, pendingPrompt == nil else { + /// True when a new prompt may be submitted: no agent run is in flight. + var isRunGateOpen: Bool { + runLifecycle == .idle + } + + func submit(workingDirectory: URL?) { + guard runLifecycle == .idle else { submissionError = "An agent run is already in progress." return } let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) guard let request = AgentRunRequest(provider: provider, approval: approval, prompt: text) else { submissionError = provider.supportsApproval - ? "Prompt contains unsupported terminal control characters." + ? "Prompt contains unsupported control characters." : "This provider is not available in the current workspace." return } - terminalSession.onInputDeliveryUpdate = { [weak self] update in - self?.receiveInputDelivery(update) + let runner = makeRunner(request, workingDirectory) + runner.onEvent = { [weak self] event in + self?.receive(event) } - terminalSession.onOutputFlush = { [weak self] in - self?.evaluateRunCompletion() + runner.onCompletion = { [weak self] outcome in + self?.finishRun(with: outcome) } - runTerminalSession = terminalSession - let agentPrompt = AgentPrompt(provider: provider, approval: approval, text: text) - guard let submission = startRequest(terminalSession, request) else { - submissionError = terminalSession.isRunning - ? "The local terminal could not accept this prompt." - : "The local terminal is still opening." + guard runner.launch() else { + submissionError = "The \(provider.title) CLI could not be launched." return } - switch submission.delivery { - case .delivered: - submittedPrompts.append(agentPrompt) - onTranscriptChanged?() - beginRun(on: terminalSession) - case .queued: - pendingPrompt = agentPrompt - pendingPromptDeliveryToken = submission.deliveryToken - } + activeRun = runner + runLifecycle = .running + transcript.append(.prompt(AgentPrompt(provider: provider, approval: approval, text: text))) prompt = "" submissionError = nil - isTerminalPresented = true + onTranscriptChanged?() + } + + func cancelRun() { + activeRun?.cancel() } /// Rebuilds the transcript from persisted thread state. Only meaningful on a /// freshly created model (restore happens before any live submission). func restoreTranscript( - prompts: [AgentPrompt], + items: [AgentTranscriptItem], provider: AgentProvider, approval: AgentApproval ) { - guard submittedPrompts.isEmpty, pendingPrompt == nil else { return } - submittedPrompts = prompts + guard transcript.isEmpty else { return } + transcript = items self.provider = provider self.approval = approval } - var isAwaitingPromptDelivery: Bool { - pendingPrompt != nil - } - - var isComposerInteractionEnabled: Bool { - pendingPrompt == nil - } - - /// True when a new prompt may be submitted: no queued write in flight and no - /// agent run currently owning the PTY. - var isRunGateOpen: Bool { - pendingPrompt == nil && runLifecycle == .idle - } - - private func receiveInputDelivery(_ update: TerminalInputDeliveryUpdate) { - switch update { - case let .delivered(deliveryToken): - guard let pendingPrompt, deliveryToken == pendingPromptDeliveryToken else { return } - submittedPrompts.append(pendingPrompt) - onTranscriptChanged?() - self.pendingPrompt = nil - pendingPromptDeliveryToken = nil - submissionError = nil - if let runTerminalSession { - beginRun(on: runTerminalSession) + private func receive(_ event: AgentStreamEvent) { + switch event { + case let .assistantText(text): + // Consecutive text blocks of one turn read as a single message. + if case let .assistant(message) = transcript.last { + transcript[transcript.count - 1] = .assistant( + AgentAssistantMessage( + id: message.id, + provider: message.provider, + text: message.text + "\n\n" + text + ) + ) + } else { + transcript.append(.assistant(AgentAssistantMessage(provider: provider, text: text))) } - case let .failed(deliveryToken, _): - guard let pendingPrompt, deliveryToken == pendingPromptDeliveryToken else { return } - failPendingPrompt(pendingPrompt) - case .sessionEnded: - if let pendingPrompt { - failPendingPrompt(pendingPrompt) + case let .toolUse(name, detail): + transcript.append(.tool(AgentToolEvent(name: name, detail: detail))) + case let .completed(isError, summary): + if isError { + let text = summary?.isEmpty == false ? summary! : "The agent reported an error." + transcript.append(.notice(AgentRunNotice(text: text, isError: true))) } - finishRun() } + // Deliberately no onTranscriptChanged here: persisting on every stream + // event rewrites the whole session index per token/tool call. The run's + // transcript is durably saved once, when the run finishes. } - private func beginRun(on terminalSession: TerminalSessionModel) { - runTerminalSession = terminalSession - runObservedForegroundCommand = false - runStartInstant = ContinuousClock.now - runLifecycle = .running + private func finishRun(with outcome: AgentRunOutcome) { + guard runLifecycle == .running else { return } + runLifecycle = .idle + activeRun = nil + switch outcome { + case .exited(0): + break + case let .exited(status): + appendFailureNoticeIfMissing("The agent exited with status \(status).") + case .terminated: + appendFailureNoticeIfMissing("The agent run was stopped.") + } + onTranscriptChanged?() } - // Completion detection (single mechanism): the PTY foreground process group. - // While the agent runs it owns the terminal's foreground group; when it exits - // the shell reclaims the foreground and redraws its prompt — that prompt is - // output, so this is re-evaluated on the next flush and the run resolves. - private func evaluateRunCompletion() { - guard runLifecycle == .running, let session = runTerminalSession else { return } - if isForegroundCommandRunning(session) { - runObservedForegroundCommand = true + /// A stream-level error event may already describe the failure; only add + /// the generic exit notice when the transcript ends without one. + private func appendFailureNoticeIfMissing(_ text: String) { + if case let .notice(notice) = transcript.last, notice.isError { return } - // Foreground is the shell. Only a completion once the agent had actually - // taken the foreground; before that it simply has not launched yet — unless - // the startup grace has elapsed, meaning the agent never launched at all - // (e.g. command not found) and the run must resolve rather than lock the gate. - guard !runObservedForegroundCommand else { - finishRun() - return - } - if let runStartInstant, ContinuousClock.now - runStartInstant > runStartupGrace { - finishRun() - } - } - - private func finishRun() { - guard runLifecycle != .idle else { return } - runLifecycle = .idle - runObservedForegroundCommand = false - runStartInstant = nil - runTerminalSession = nil - } - - private func failPendingPrompt(_ pendingPrompt: AgentPrompt) { - self.pendingPrompt = nil - pendingPromptDeliveryToken = nil - provider = pendingPrompt.provider - approval = pendingPrompt.approval - prompt = pendingPrompt.text - submissionError = "The queued prompt was not delivered." + transcript.append(.notice(AgentRunNotice(text: text, isError: true))) } } diff --git a/Sources/MikuCodeApp/Agent/HeadlessAgentRunner.swift b/Sources/MikuCodeApp/Agent/HeadlessAgentRunner.swift new file mode 100644 index 0000000..2ef0a0f --- /dev/null +++ b/Sources/MikuCodeApp/Agent/HeadlessAgentRunner.swift @@ -0,0 +1,150 @@ +import Foundation + +enum AgentRunOutcome: Equatable { + case exited(Int32) + case terminated +} + +/// One agent run: launched once, streams normalized events, reports completion +/// exactly once. Abstracted so tests drive the workspace model without spawning +/// real processes. +@MainActor +protocol AgentRunProcess: AnyObject { + var onEvent: ((AgentStreamEvent) -> Void)? { get set } + var onCompletion: ((AgentRunOutcome) -> Void)? { get set } + func launch() -> Bool + func cancel() +} + +/// Runs one headless agent turn (`claude -p --output-format stream-json` / +/// `codex exec --json`) as a subprocess of the user's login shell so the CLI is +/// resolved from their normal PATH. Stdout JSONL is parsed into transcript +/// events; the terminal PTY is never involved — it stays a plain auxiliary +/// shell. +@MainActor +final class HeadlessAgentRunner: AgentRunProcess { + var onEvent: ((AgentStreamEvent) -> Void)? + var onCompletion: ((AgentRunOutcome) -> Void)? + + private let command: String + private let workingDirectory: URL? + private let process = Process() + private let stdoutPipe = Pipe() + private let stderrPipe = Pipe() + private let lines = LineAccumulator() + private let parser = AgentEventStreamParser() + private var didComplete = false + + convenience init(request: AgentRunRequest, workingDirectory: URL?) { + self.init(command: request.headlessCommand, workingDirectory: workingDirectory) + } + + /// Exposed for tests, which substitute a script that prints JSONL fixtures + /// for the real CLI. + init(command: String, workingDirectory: URL?) { + self.command = command + self.workingDirectory = workingDirectory + } + + func launch() -> Bool { + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", command] + if let workingDirectory { + process.currentDirectoryURL = workingDirectory + } + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + // Drain stderr so the child never blocks on a full pipe; its content is + // not part of the structured stream. + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + _ = handle.availableData + } + let accumulator = lines + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + let completeLines = accumulator.append(data) + guard !completeLines.isEmpty, let self else { return } + Task { @MainActor in + self.emit(lines: completeLines) + } + } + process.terminationHandler = { [weak self] finished in + let status = finished.terminationStatus + let reason = finished.terminationReason + guard let self else { return } + Task { @MainActor in + self.finish(status: status, reason: reason) + } + } + do { + try process.run() + return true + } catch { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + return false + } + } + + func cancel() { + guard process.isRunning else { return } + process.terminate() + } + + private func emit(lines: [String]) { + guard !didComplete else { return } + for line in lines { + for event in parser.events(fromLine: line) { + onEvent?(event) + } + } + } + + private func finish(status: Int32, reason: Process.TerminationReason) { + guard !didComplete else { return } + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + // Flush any final partial line the handler had not seen yet. + let remainder = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? nil + var tail = lines.append(remainder ?? Data()) + if let last = lines.flush() { + tail.append(last) + } + emit(lines: tail) + didComplete = true + onCompletion?(reason == .uncaughtSignal ? .terminated : .exited(status)) + } +} + +/// Thread-safe byte-to-line splitter shared between the pipe's readability +/// handler (background queue) and the main-actor flush at termination. +final class LineAccumulator: @unchecked Sendable { + private var buffer = Data() + private let lock = NSLock() + + /// Appends raw bytes and returns every newline-terminated line completed by + /// this chunk. + func append(_ data: Data) -> [String] { + lock.lock() + defer { lock.unlock() } + buffer.append(data) + var complete: [String] = [] + while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) { + let lineData = buffer[buffer.startIndex ..< newline] + complete.append(String(decoding: lineData, as: UTF8.self)) + buffer.removeSubrange(buffer.startIndex ... newline) + } + return complete + } + + /// Returns the trailing unterminated line, if any, and clears the buffer. + func flush() -> String? { + lock.lock() + defer { lock.unlock() } + guard !buffer.isEmpty else { return nil } + let line = String(decoding: buffer, as: UTF8.self) + buffer.removeAll() + return line + } +} diff --git a/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift b/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift index c5dbbd5..cbb0327 100644 --- a/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift +++ b/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift @@ -64,24 +64,37 @@ final class WorkspaceSessionStore: ObservableObject { @Published private(set) var defaultWorkingDirectory: URL? private let repository: WorkspaceSessionsRepository private let shell: String? + private let makeAgent: () -> AgentWorkspaceModel private var isStarted = false init( repository: WorkspaceSessionsRepository = WorkspaceSessionsRepository(), initialTerminal: TerminalSessionModel? = nil, - shell: String? = nil + shell: String? = nil, + makeAgent: @escaping () -> AgentWorkspaceModel = { AgentWorkspaceModel() } ) { self.repository = repository self.shell = shell + self.makeAgent = makeAgent if let initialTerminal { // Injected terminal (tests, previews): single deterministic thread. - let entry = WorkspaceSessionEntry(terminal: initialTerminal) + let entry = WorkspaceSessionEntry(terminal: initialTerminal, agent: makeAgent()) wire(entry) sessions = [entry] selectedID = entry.id } else if let index = repository.loadIndex() { restore(from: index) } + // New threads should never run "nowhere": until the user picks a folder, + // ~/Desktop is the working directory, mirroring the terminal's default. + if defaultWorkingDirectory == nil { + defaultWorkingDirectory = Self.desktopDirectory + } + } + + private static var desktopDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Desktop", isDirectory: true) } var selected: WorkspaceSessionEntry? { @@ -100,7 +113,8 @@ final class WorkspaceSessionStore: ObservableObject { let entry = WorkspaceSessionEntry( id: id, workingDirectory: directory, - terminal: terminal + terminal: terminal, + agent: makeAgent() ) wire(entry) sessions.append(entry) @@ -150,11 +164,14 @@ final class WorkspaceSessionStore: ObservableObject { persist() } - /// Remove a thread: stop its shell, delete its persisted terminal snapshot, - /// and move the selection to a neighboring thread (or none). + /// Remove a thread: stop its agent run and shell, delete its persisted + /// terminal snapshot, and move the selection to a neighboring thread (or + /// none). Cancelling the run matters: a released model cannot kill its + /// subprocess, so skipping this would leave the agent running invisibly. func deleteSession(_ id: UUID) { guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } let entry = sessions[index] + entry.agent.cancelRun() entry.terminal.stop() repository.deleteTerminalSnapshot(for: id) sessions.remove(at: index) @@ -186,7 +203,8 @@ final class WorkspaceSessionStore: ObservableObject { approval: $0.approval.rawValue, text: $0.text ) - } + }, + transcript: entry.agent.transcript.map(Self.persistedItem) ) } ) @@ -203,30 +221,40 @@ final class WorkspaceSessionStore: ObservableObject { defaultWorkingDirectory = index.defaultWorkingDirectory.map { URL(fileURLWithPath: $0, isDirectory: true) } + let fallbackDirectory = defaultWorkingDirectory ?? Self.desktopDirectory sessions = index.sessions.map { persisted in + // Threads persisted before the Desktop default get it on restore so + // no thread runs without a working directory. let workingDirectory = persisted.workingDirectory.map { URL(fileURLWithPath: $0, isDirectory: true) - } + } ?? fallbackDirectory let terminal = TerminalSessionModel( persistence: repository.terminalPersistence(for: persisted.id), shell: shell, - initialWorkingDirectory: workingDirectory?.path + initialWorkingDirectory: workingDirectory.path ) let entry = WorkspaceSessionEntry( id: persisted.id, createdAt: persisted.createdAt, workingDirectory: workingDirectory, customTitle: persisted.customTitle, - terminal: terminal + terminal: terminal, + agent: makeAgent() ) - entry.agent.restoreTranscript( - prompts: persisted.prompts.map { - AgentPrompt( + let items: [AgentTranscriptItem] = if let transcript = persisted.transcript { + transcript.compactMap(Self.transcriptItem) + } else { + // Pre-transcript index files only recorded the prompts. + persisted.prompts.map { + .prompt(AgentPrompt( provider: AgentProvider(rawValue: $0.provider) ?? .codex, approval: AgentApproval(rawValue: $0.approval) ?? .accept, text: $0.text - ) - }, + )) + } + } + entry.agent.restoreTranscript( + items: items, provider: AgentProvider(rawValue: persisted.provider) ?? .codex, approval: AgentApproval(rawValue: persisted.approval) ?? .accept ) @@ -245,6 +273,50 @@ final class WorkspaceSessionStore: ObservableObject { self?.persist() } } + + private static func persistedItem(_ item: AgentTranscriptItem) -> PersistedWorkspaceTranscriptItem { + switch item { + case let .prompt(prompt): + PersistedWorkspaceTranscriptItem( + kind: "prompt", + provider: prompt.provider.rawValue, + approval: prompt.approval.rawValue, + text: prompt.text + ) + case let .assistant(message): + PersistedWorkspaceTranscriptItem( + kind: "assistant", + provider: message.provider.rawValue, + text: message.text + ) + case let .tool(event): + PersistedWorkspaceTranscriptItem(kind: "tool", name: event.name, detail: event.detail) + case let .notice(notice): + PersistedWorkspaceTranscriptItem(kind: "notice", text: notice.text, isError: notice.isError) + } + } + + private static func transcriptItem(_ item: PersistedWorkspaceTranscriptItem) -> AgentTranscriptItem? { + switch item.kind { + case "prompt": + .prompt(AgentPrompt( + provider: AgentProvider(rawValue: item.provider ?? "") ?? .codex, + approval: AgentApproval(rawValue: item.approval ?? "") ?? .accept, + text: item.text ?? "" + )) + case "assistant": + .assistant(AgentAssistantMessage( + provider: AgentProvider(rawValue: item.provider ?? "") ?? .codex, + text: item.text ?? "" + )) + case "tool": + .tool(AgentToolEvent(name: item.name ?? "tool", detail: item.detail)) + case "notice": + .notice(AgentRunNotice(text: item.text ?? "", isError: item.isError ?? false)) + default: + nil + } + } } extension WorkspaceSessionStore: PresentationTerminalLifecycle { @@ -257,7 +329,10 @@ extension WorkspaceSessionStore: PresentationTerminalLifecycle { func stop() { isStarted = false - sessions.forEach { $0.terminal.stop() } + sessions.forEach { entry in + entry.agent.cancelRun() + entry.terminal.stop() + } persist() } } diff --git a/Sources/MikuCodeApp/Agent/WorkspaceSessionsRepository.swift b/Sources/MikuCodeApp/Agent/WorkspaceSessionsRepository.swift index 1f4fd30..a6af818 100644 --- a/Sources/MikuCodeApp/Agent/WorkspaceSessionsRepository.swift +++ b/Sources/MikuCodeApp/Agent/WorkspaceSessionsRepository.swift @@ -12,6 +12,9 @@ struct PersistedWorkspaceSession: Codable, Equatable { let provider: String let approval: String let prompts: [PersistedWorkspacePrompt] + /// Full conversation (prompts, assistant messages, tool events); decodes as + /// nil from pre-transcript index files, which then restore from `prompts`. + let transcript: [PersistedWorkspaceTranscriptItem]? } struct PersistedWorkspacePrompt: Codable, Equatable { @@ -20,6 +23,34 @@ struct PersistedWorkspacePrompt: Codable, Equatable { let text: String } +struct PersistedWorkspaceTranscriptItem: Codable, Equatable { + let kind: String + let provider: String? + let approval: String? + let text: String? + let name: String? + let detail: String? + let isError: Bool? + + init( + kind: String, + provider: String? = nil, + approval: String? = nil, + text: String? = nil, + name: String? = nil, + detail: String? = nil, + isError: Bool? = nil + ) { + self.kind = kind + self.provider = provider + self.approval = approval + self.text = text + self.name = name + self.detail = detail + self.isError = isError + } +} + struct PersistedWorkspaceSessionIndex: Codable, Equatable { let schemaVersion: Int let selectedID: UUID? diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index f7360ce..c686f89 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -67,6 +67,12 @@ private struct SessionContentView: View { let onNewThread: () -> Void let onChangeFolder: () -> Void @FocusState private var isPromptFocused: Bool + private static let conversationBottomAnchor = "conversation-bottom" + // User-dragged terminal height; nil falls back to the workspace-derived + // default. The base height is pinned at drag start so the live resize + // tracks the cursor instead of compounding per-frame deltas. + @State private var terminalHeightOverride: CGFloat? + @State private var terminalDragBaseHeight: CGFloat? var body: some View { VStack(spacing: 0) { @@ -74,7 +80,12 @@ private struct SessionContentView: View { conversation composer if workspace.isTerminalPresented { - embeddedTerminal(height: AgentWorkspaceLayout.terminalHeight(for: workspaceHeight)) + embeddedTerminal( + height: AgentWorkspaceLayout.clampedTerminalHeight( + terminalHeightOverride ?? AgentWorkspaceLayout.terminalHeight(for: workspaceHeight), + workspaceHeight: workspaceHeight + ) + ) } } .onAppear { isPromptFocused = true } @@ -113,9 +124,53 @@ private struct SessionContentView: View { } private var conversation: some View { - ScrollView { + ScrollViewReader { proxy in + ScrollView { + conversationContent + } + .frame(maxHeight: .infinity) + .onChange(of: scrollSignal) { _, _ in + scrollToBottom(proxy: proxy) + } + .onChange(of: workspace.runLifecycle) { _, _ in + scrollToBottom(proxy: proxy) + } + .onAppear { + scrollToBottom(proxy: proxy, animated: false) + } + } + } + + /// Combines transcript length with the last item's content length so + /// streaming text growth (which mutates the last element in place rather + /// than appending) still triggers a scroll. + private var scrollSignal: Int { + workspace.transcript.count &* 131_071 &+ lastItemContentLength + } + + private var lastItemContentLength: Int { + switch workspace.transcript.last { + case let .prompt(prompt): prompt.text.count + case let .assistant(message): message.text.count + case let .tool(event): event.detail?.count ?? 0 + case let .notice(notice): notice.text.count + case .none: 0 + } + } + + private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) { + guard animated else { + proxy.scrollTo(Self.conversationBottomAnchor, anchor: .bottom) + return + } + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(Self.conversationBottomAnchor, anchor: .bottom) + } + } + + private var conversationContent: some View { VStack(alignment: .leading, spacing: 14) { - if workspace.submittedPrompts.isEmpty, workspace.pendingPrompt == nil { + if workspace.transcript.isEmpty { VStack(spacing: 18) { WorkspaceWelcome() Button(action: onNewThread) { @@ -139,24 +194,27 @@ private struct SessionContentView: View { .frame(maxWidth: .infinity) .padding(.top, 96) } else { - ForEach(Array(workspace.submittedPrompts.enumerated()), id: \.element.id) { index, prompt in - let isCurrentRun = index == workspace.submittedPrompts.count - 1 - && workspace.runLifecycle == .running - AgentPromptCard(prompt: prompt, status: isCurrentRun ? "running" : "sent") - } - if let prompt = workspace.pendingPrompt { - AgentPromptCard(prompt: prompt, status: "sending") - } - if !workspace.submittedPrompts.isEmpty { - HStack(spacing: 7) { - Circle() - .fill(AgentWorkspacePalette.cyan) - .frame(width: 6, height: 6) - Text("Running in the integrated terminal") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(AgentWorkspacePalette.muted) + let runningPromptID = workspace.runLifecycle == .running + ? workspace.submittedPrompts.last?.id + : nil + ForEach(workspace.transcript) { item in + switch item { + case let .prompt(prompt): + AgentPromptCard( + prompt: prompt, + status: prompt.id == runningPromptID ? "running" : "sent" + ) + case let .assistant(message): + AssistantMessageCard(message: message) + case let .tool(event): + ToolEventRow(event: event) + case let .notice(notice): + RunNoticeRow(notice: notice) } - .padding(.top, 2) + } + if workspace.runLifecycle == .running { + AgentActivityIndicator(label: runActivityLabel) + .padding(.top, 2) } } if let error = workspace.submissionError { @@ -164,14 +222,37 @@ private struct SessionContentView: View { .font(.system(size: 11, weight: .medium)) .foregroundStyle(AgentWorkspacePalette.error) } + // Zero-height anchor the auto-scroll targets; keeps the scroll + // target stable regardless of which transcript row last mutated. + Color.clear + .frame(height: 1) + .id(Self.conversationBottomAnchor) } .frame(maxWidth: AgentWorkspaceLayout.contentColumnWidth, alignment: .leading) .padding(.horizontal, 28) .padding(.bottom, 26) // Center the conversation column so it lines up with the composer. .frame(maxWidth: .infinity, alignment: .top) + } + + /// "Thinking" until the current run produces its first visible output + /// (assistant text or a tool call), then "working" while it continues. + private var runActivityLabel: String { + let provider = workspace.submittedPrompts.last?.provider.title ?? "The agent" + guard let promptIndex = workspace.transcript.lastIndex(where: { item in + if case .prompt = item { return true } + return false + }) else { + return "\(provider) is thinking…" } - .frame(maxHeight: .infinity) + let hasVisibleOutput = workspace.transcript[workspace.transcript.index(after: promptIndex)...] + .contains { item in + switch item { + case .assistant, .tool: true + case .prompt, .notice: false + } + } + return hasVisibleOutput ? "\(provider) is working…" : "\(provider) is thinking…" } /// Codex-Desktop-style composer: one elevated bright input card holding the @@ -189,7 +270,6 @@ private struct SessionContentView: View { .padding(.trailing, 18 + AgentWorkspaceLayout.bubbleTailInset) .padding(.top, 6) .padding(.bottom, 16) - .disabled(!workspace.isComposerInteractionEnabled) .animation(.easeOut(duration: 0.15), value: isPromptFocused) .task(id: entry.workingDirectory) { gitBranch = Self.gitBranch(at: entry.workingDirectory) @@ -314,7 +394,11 @@ private struct SessionContentView: View { } } Spacer(minLength: 8) - WorkspaceSendButton(enabled: canSubmit, action: submit) + WorkspaceComposerButton( + action: composerAction(for: workspace.runLifecycle, canSubmit: canSubmit), + onSend: submit, + onStop: workspace.cancelRun + ) } .padding(.horizontal, 10) .padding(.top, 8) @@ -372,10 +456,14 @@ private struct SessionContentView: View { } private func embeddedTerminal(height: CGFloat) -> some View { + // VS-Code-style bottom panel: the divider is also the resize handle. + // Drag it vertically to resize; dragging below the close threshold + // dismisses the panel, and press-and-hold still closes it in place. VStack(spacing: 0) { - // Clear separator so the terminal reads as its own panel, like an - // IDE's bottom terminal divider. HStack(spacing: 8) { + Text("❯") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(AgentWorkspacePalette.cyan) Text("TERMINAL") .font(.system(size: 9, weight: .semibold, design: .monospaced)) .tracking(1.2) @@ -383,11 +471,28 @@ private struct SessionContentView: View { Rectangle() .fill(Color.white.opacity(0.10)) .frame(height: 1) + Text("DRAG TO RESIZE · HOLD TO CLOSE") + .font(.system(size: 8, weight: .semibold, design: .monospaced)) + .tracking(1.0) + .foregroundStyle(AgentWorkspacePalette.subtle.opacity(0.7)) } .padding(.leading, 14) .padding(.trailing, 14 + AgentWorkspaceLayout.bubbleTailInset) .frame(height: 24) .background(AgentWorkspacePalette.headerGradient) + .contentShape(Rectangle()) + .onHover { isHovering in + if isHovering { + NSCursor.resizeUpDown.set() + } else { + NSCursor.arrow.set() + } + } + .gesture(terminalResizeGesture(currentHeight: height)) + .onLongPressGesture(minimumDuration: 0.5) { + closeTerminal() + } + .help("Drag to resize; drag below the panel or press and hold to close") TerminalPanel( session: terminalSession, focusRequestID: terminalFocusRequestID, @@ -406,24 +511,54 @@ private struct SessionContentView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } + private func terminalResizeGesture(currentHeight: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 2) + .onChanged { value in + let base = terminalDragBaseHeight ?? currentHeight + terminalDragBaseHeight = base + terminalHeightOverride = AgentWorkspaceLayout.resizedTerminalHeight( + base: base, + dragTranslation: value.translation.height, + workspaceHeight: workspaceHeight + ) + } + .onEnded { value in + let base = terminalDragBaseHeight ?? currentHeight + terminalDragBaseHeight = nil + if AgentWorkspaceLayout.terminalDragShouldClose( + base: base, + dragTranslation: value.translation.height + ) { + closeTerminal() + } + } + } + + private func closeTerminal() { + withAnimation(.easeOut(duration: 0.18)) { + workspace.isTerminalPresented = false + } + terminalHeightOverride = nil + terminalDragBaseHeight = nil + isPromptFocused = true + } + private var canSubmit: Bool { - terminalSession.isRunning - && workspace.provider.supportsApproval + workspace.provider.supportsApproval && workspace.isRunGateOpen && !workspace.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func submit() { guard canSubmit else { return } - workspace.submit(to: terminalSession) - // Hand first responder to the terminal so the running agent owns keyboard - // input; @FocusState must release or it can reclaim the NSTextView. - isPromptFocused = false - onTerminalFocus() + // The run streams into the main transcript; the composer keeps focus and + // the auxiliary terminal stays exactly as the user left it. + workspace.submit(workingDirectory: entry.workingDirectory) + isPromptFocused = true } } -private enum AgentWorkspaceLayout { +enum AgentWorkspaceLayout { static let headerHeight: CGFloat = 52 // Sessions sidebar column on the bubble's leading edge; the native traffic // lights overlay its top, so its content starts below them. @@ -435,10 +570,38 @@ private enum AgentWorkspaceLayout { // (SpeechBubbleShape insets the bubble body by up to 40pt); content must not // lay out into that clipped zone. static let bubbleTailInset: CGFloat = 40 + // Bounds for the drag-resizable bottom terminal. Dragging the divider so + // the panel would shrink under the close threshold dismisses it instead of + // pinning it at the minimum. + static let terminalMinHeight: CGFloat = 90 + static let terminalCloseThreshold: CGFloat = 56 static func terminalHeight(for workspaceHeight: CGFloat) -> CGFloat { min(280, max(116, workspaceHeight * 0.34)) } + + static func maximumTerminalHeight(for workspaceHeight: CGFloat) -> CGFloat { + max(terminalMinHeight, workspaceHeight * 0.6) + } + + static func clampedTerminalHeight(_ height: CGFloat, workspaceHeight: CGFloat) -> CGFloat { + min(maximumTerminalHeight(for: workspaceHeight), max(terminalMinHeight, height)) + } + + /// The divider sits above the panel, so dragging down (positive translation) + /// shrinks it. The live height clamps to the panel bounds; closing is + /// decided separately from the unclamped value at drag end. + static func resizedTerminalHeight( + base: CGFloat, + dragTranslation: CGFloat, + workspaceHeight: CGFloat + ) -> CGFloat { + clampedTerminalHeight(base - dragTranslation, workspaceHeight: workspaceHeight) + } + + static func terminalDragShouldClose(base: CGFloat, dragTranslation: CGFloat) -> Bool { + base - dragTranslation < terminalCloseThreshold + } } private struct WorkspaceWelcome: View { @@ -848,6 +1011,129 @@ private struct AgentPromptCard: View { } } +/// Live "thinking/working" pulse shown while a run streams: three dots breathe +/// in sequence next to the status label. Falls back to a static indicator when +/// Reduce Motion is on. +private struct AgentActivityIndicator: View { + let label: String + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isPulsing = false + + var body: some View { + HStack(spacing: 8) { + HStack(spacing: 3) { + ForEach(0 ..< 3, id: \.self) { index in + Circle() + .fill(AgentWorkspacePalette.cyan) + .frame(width: 4, height: 4) + .opacity(reduceMotion ? 0.85 : (isPulsing ? 1.0 : 0.25)) + .animation( + reduceMotion + ? nil + : .easeInOut(duration: 0.45) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.15), + value: isPulsing + ) + } + } + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AgentWorkspacePalette.muted) + } + .onAppear { + guard !reduceMotion else { return } + isPulsing = true + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(label) + } +} + +/// A streamed agent reply rendered as conversation text in the main view. +private struct AssistantMessageCard: View { + let message: AgentAssistantMessage + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 7) { + ProviderMark(provider: message.provider, size: 12) + Text(message.provider.title.uppercased()) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(message.provider.tint) + } + assistantText + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AgentWorkspacePalette.muted) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(15) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.035)) + .overlay(alignment: .leading) { + Rectangle().fill(AgentWorkspacePalette.hairline).frame(width: 2) + } + .clipShape(RoundedRectangle(cornerRadius: AgentWorkspacePalette.cardRadius)) + .accessibilityLabel("\(message.provider.title) reply") + } + + /// Raw agent output must not be treated as a localization key: that + /// mangles multi-paragraph markdown and misinterprets literal `%`/`{` + /// characters. Render inline markdown and fall back to verbatim text. + private var assistantText: Text { + if let attributed = try? AttributedString( + markdown: message.text, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return Text(attributed) + } + return Text(verbatim: message.text) + } +} + +/// A compact one-line row for a tool call; file-modifying tools read as code +/// changes (pencil icon, cyan tint). +private struct ToolEventRow: View { + let event: AgentToolEvent + + var body: some View { + HStack(spacing: 7) { + Image(systemName: event.isCodeChange ? "pencil.line" : "wrench.and.screwdriver.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(event.isCodeChange ? AgentWorkspacePalette.cyan : AgentWorkspacePalette.subtle) + Text(event.name) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(event.isCodeChange ? AgentWorkspacePalette.cyan : AgentWorkspacePalette.muted) + if let detail = event.detail { + Text(detail) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(AgentWorkspacePalette.subtle) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.white.opacity(0.03)) + .clipShape(RoundedRectangle(cornerRadius: AgentWorkspacePalette.controlRadius)) + .accessibilityLabel( + event.isCodeChange ? "Code change: \(event.name)" : "Tool call: \(event.name)" + ) + } +} + +private struct RunNoticeRow: View { + let notice: AgentRunNotice + + var body: some View { + Label(notice.text, systemImage: notice.isError ? "exclamationmark.circle" : "info.circle") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(notice.isError ? AgentWorkspacePalette.error : AgentWorkspacePalette.muted) + } +} + private struct AgentTag: View { let title: String let tint: Color @@ -893,24 +1179,51 @@ private struct AgentChoicePill: View { } } +/// The composer's primary button: send (idle) or stop (running). Pure so the +/// send-vs-stop decision is unit-testable without instantiating SwiftUI. +enum ComposerAction: Equatable { + case send(enabled: Bool) + case stop +} + +/// While a run is in flight `canSubmit` is already false (the run gate is +/// closed), so the button must switch to stop independently of it; stop stays +/// enabled for the whole run so the user can always cancel. +func composerAction(for runLifecycle: AgentWorkspaceModel.RunLifecycle, canSubmit: Bool) -> ComposerAction { + runLifecycle == .running ? .stop : .send(enabled: canSubmit) +} + /// Codex-Desktop-style primary action: a circular teal arrow-up living inside /// the composer card, with hover/pressed feedback and a dimmed-but-visible -/// disabled state. -private struct WorkspaceSendButton: View { - let enabled: Bool - let action: () -> Void +/// disabled state. Switches to a stop button while the agent run is active. +private struct WorkspaceComposerButton: View { + let action: ComposerAction + let onSend: () -> Void + let onStop: () -> Void @State private var isHovered = false var body: some View { - Button(action: action) { - Image(systemName: "arrow.up") - .font(.system(size: 14, weight: .bold)) - .frame(width: 34, height: 34) - } - .buttonStyle(WorkspaceSendButtonStyle(enabled: enabled, isHovered: isHovered)) - .disabled(!enabled) - .onHover { isHovered = enabled && $0 } - .accessibilityLabel("Start coding session") + switch action { + case let .send(enabled): + Button(action: onSend) { + Image(systemName: "arrow.up") + .font(.system(size: 14, weight: .bold)) + .frame(width: 34, height: 34) + } + .buttonStyle(WorkspaceSendButtonStyle(enabled: enabled, isHovered: isHovered)) + .disabled(!enabled) + .onHover { isHovered = enabled && $0 } + .accessibilityLabel("Start coding session") + case .stop: + Button(action: onStop) { + Image(systemName: "stop.fill") + .font(.system(size: 14, weight: .bold)) + .frame(width: 34, height: 34) + } + .buttonStyle(WorkspaceSendButtonStyle(enabled: true, isHovered: isHovered)) + .onHover { isHovered = $0 } + .accessibilityLabel("Stop the agent run") + } } } diff --git a/Tests/MikuCodeAppTests/AgentEventStreamTests.swift b/Tests/MikuCodeAppTests/AgentEventStreamTests.swift new file mode 100644 index 0000000..631dd5c --- /dev/null +++ b/Tests/MikuCodeAppTests/AgentEventStreamTests.swift @@ -0,0 +1,138 @@ +import XCTest +@testable import MikuCodeApp + +final class AgentEventStreamTests: XCTestCase { + private let parser = AgentEventStreamParser() + + func testClaudeAssistantTextAndToolUseDecodeInBlockOrder() { + let line = """ + {"type":"assistant","message":{"content":[\ + {"type":"text","text":"I will update the view."},\ + {"type":"tool_use","name":"Edit","input":{"file_path":"Sources/App/Main.swift","old_string":"a"}}]}} + """ + + XCTAssertEqual(parser.events(fromLine: line), [ + .assistantText("I will update the view."), + .toolUse(name: "Edit", detail: "Sources/App/Main.swift") + ]) + } + + func testClaudeToolUseWithoutPathFallsBackToCommandThenNil() { + let bash = """ + {"type":"assistant","message":{"content":[\ + {"type":"tool_use","name":"Bash","input":{"command":"swift test"}}]}} + """ + let bare = """ + {"type":"assistant","message":{"content":[{"type":"tool_use","name":"TodoWrite","input":{"todos":[]}}]}} + """ + + XCTAssertEqual(parser.events(fromLine: bash), [.toolUse(name: "Bash", detail: "swift test")]) + XCTAssertEqual(parser.events(fromLine: bare), [.toolUse(name: "TodoWrite", detail: nil)]) + } + + func testClaudeResultDecodesSuccessAndError() { + let success = "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"All done\"}" + let failure = "{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"is_error\":true,\"result\":\"Boom\"}" + + XCTAssertEqual(parser.events(fromLine: success), [.completed(isError: false, summary: "All done")]) + XCTAssertEqual(parser.events(fromLine: failure), [.completed(isError: true, summary: "Boom")]) + } + + func testCodexThreadEventsDecodeItemsTurnsAndErrors() { + // Fixtures follow codex-cli 0.141 `exec --json` ThreadEvent output + // captured from a real run (top-level `type`, payload under `item`). + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"thread.started\",\"thread_id\":\"019f7ee6\"}"), + [] + ) + XCTAssertEqual(parser.events(fromLine: "{\"type\":\"turn.started\"}"), []) + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_1\",\"type\":\"agent_message\",\"text\":\"Working on it.\"}}"), + [.assistantText("Working on it.")] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"item.started\",\"item\":{\"id\":\"item_2\",\"type\":\"command_execution\",\"command\":\"git status\"}}"), + [.toolUse(name: "shell", detail: "git status")] + ) + // The completed command item must not render a second row. + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"command_execution\",\"command\":\"git status\",\"exit_code\":0}}"), + [] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"item.started\",\"item\":{\"id\":\"item_3\",\"type\":\"file_change\"}}"), + [.toolUse(name: "apply_patch", detail: nil)] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"error\",\"message\":\"feature unavailable\"}}"), + [.completed(isError: true, summary: "feature unavailable")] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10}}"), + [.completed(isError: false, summary: nil)] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"turn.failed\",\"error\":{\"message\":\"model unavailable\"}}"), + [.completed(isError: true, summary: "model unavailable")] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"error\",\"message\":\"stream disconnected\"}"), + [.completed(isError: true, summary: "stream disconnected")] + ) + } + + func testLegacyCodexMsgDialectStillDecodes() { + XCTAssertEqual( + parser.events(fromLine: "{\"id\":\"0\",\"msg\":{\"type\":\"agent_message\",\"message\":\"Working on it.\"}}"), + [.assistantText("Working on it.")] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"id\":\"0\",\"msg\":{\"type\":\"exec_command_begin\",\"command\":[\"git\",\"status\"]}}"), + [.toolUse(name: "shell", detail: "git status")] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"id\":\"0\",\"msg\":{\"type\":\"patch_apply_begin\"}}"), + [.toolUse(name: "apply_patch", detail: nil)] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"id\":\"0\",\"msg\":{\"type\":\"error\",\"message\":\"rate limited\"}}"), + [.completed(isError: true, summary: "rate limited")] + ) + XCTAssertEqual( + parser.events(fromLine: "{\"id\":\"0\",\"msg\":{\"type\":\"task_complete\",\"last_agent_message\":\"Done\"}}"), + [.completed(isError: false, summary: "Done")] + ) + } + + func testUnknownAndMalformedLinesDecodeToNoEvents() { + XCTAssertEqual(parser.events(fromLine: ""), []) + XCTAssertEqual(parser.events(fromLine: "warning: something on stderr leaked"), []) + XCTAssertEqual(parser.events(fromLine: "{\"type\":\"system\",\"subtype\":\"init\"}"), []) + XCTAssertEqual(parser.events(fromLine: "{\"id\":\"0\",\"msg\":{\"type\":\"token_count\",\"total\":12}}"), []) + XCTAssertEqual(parser.events(fromLine: "{not json"), []) + // Empty assistant text renders nothing rather than an empty bubble. + XCTAssertEqual( + parser.events(fromLine: "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"\"}]}}"), + [] + ) + } + + func testLineAccumulatorSplitsChunksAcrossBoundaries() { + let accumulator = LineAccumulator() + + XCTAssertEqual(accumulator.append(Data("{\"a\":1}\n{\"b\"".utf8)), ["{\"a\":1}"]) + XCTAssertEqual(accumulator.append(Data(":2}\n".utf8)), ["{\"b\":2}"]) + XCTAssertNil(accumulator.flush()) + + XCTAssertEqual(accumulator.append(Data("tail".utf8)), []) + XCTAssertEqual(accumulator.flush(), "tail") + XCTAssertNil(accumulator.flush()) + + // One chunk carrying several complete lines splits them all at once. + XCTAssertEqual( + accumulator.append(Data("a\nb\nc\ntrailing".utf8)), + ["a", "b", "c"] + ) + XCTAssertEqual(accumulator.flush(), "trailing") + } +} diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift index 677e90f..49f179b 100644 --- a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -3,17 +3,26 @@ import XCTest @MainActor final class AgentRunRequestTests: XCTestCase { - func testCodexCommandUsesApprovalSandbox() throws { + func testCodexCommandUsesApprovalSandboxAndStructuredOutput() throws { let plan = try XCTUnwrap(AgentRunRequest(provider: .codex, approval: .plan, prompt: "inspect")) let accept = try XCTUnwrap(AgentRunRequest(provider: .codex, approval: .accept, prompt: "edit")) let auto = try XCTUnwrap(AgentRunRequest(provider: .codex, approval: .auto, prompt: "ship")) - XCTAssertEqual(plan.shellCommand, "codex exec --sandbox read-only 'inspect'") - XCTAssertEqual(accept.shellCommand, "codex exec --sandbox workspace-write 'edit'") - XCTAssertEqual(auto.shellCommand, "codex exec --sandbox danger-full-access 'ship'") + XCTAssertEqual( + plan.headlessCommand, + "codex exec --json --skip-git-repo-check --sandbox read-only 'inspect'" + ) + XCTAssertEqual( + accept.headlessCommand, + "codex exec --json --skip-git-repo-check --sandbox workspace-write 'edit'" + ) + XCTAssertEqual( + auto.headlessCommand, + "codex exec --json --skip-git-repo-check --sandbox danger-full-access 'ship'" + ) } - func testClaudeCommandUsesMatchingPermissionMode() throws { + func testClaudeCommandStreamsJSONWithMatchingPermissionMode() throws { let request = try XCTUnwrap(AgentRunRequest( provider: .claude, approval: .accept, @@ -21,8 +30,9 @@ final class AgentRunRequestTests: XCTestCase { )) XCTAssertEqual( - request.shellCommand, - "claude --permission-mode acceptEdits 'make the change'" + request.headlessCommand, + "claude -p --verbose --output-format stream-json" + + " --permission-mode acceptEdits 'make the change'" ) } @@ -33,14 +43,17 @@ final class AgentRunRequestTests: XCTestCase { prompt: "fix user's command" )) - XCTAssertEqual(request.shellCommand, "codex exec --sandbox read-only 'fix user'\"'\"'s command'") + XCTAssertEqual( + request.headlessCommand, + "codex exec --json --skip-git-repo-check --sandbox read-only 'fix user'\"'\"'s command'" + ) } func testRejectsUnsupportedProviderAndTerminalControlCharacters() { XCTAssertNil(AgentRunRequest(provider: .pi, approval: .auto, prompt: "inspect")) XCTAssertNil(AgentRunRequest(provider: .codex, approval: .accept, prompt: "stop\u{0003}now")) // ESC (used by terminal escape sequences) must still be rejected even - // though newline and tab are now allowed. + // though newline and tab are allowed. XCTAssertNil(AgentRunRequest(provider: .codex, approval: .accept, prompt: "esc\u{001B}[2J")) } @@ -51,257 +64,257 @@ final class AgentRunRequestTests: XCTestCase { prompt: "first line\nsecond line\twith tab" )) - // POSIX single quotes keep newline and tab literal, so the two-line prompt - // survives the shell wrapping intact as a single argument. XCTAssertEqual( - request.shellCommand, - "codex exec --sandbox workspace-write 'first line\nsecond line\twith tab'" + request.headlessCommand, + "codex exec --json --skip-git-repo-check --sandbox workspace-write 'first line\nsecond line\twith tab'" ) } +} - func testRunGateBlocksSecondSubmissionWhileAgentRuns() { - let probe = ForegroundStateProbe() - let workspace = AgentWorkspaceModel( - startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) - }, - isForegroundCommandRunning: { _ in probe.isRunning } - ) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) +@MainActor +final class AgentWorkspaceModelTests: XCTestCase { + private func makeWorkspace() -> (AgentWorkspaceModel, RunnerSpy) { + let spy = RunnerSpy() + let workspace = AgentWorkspaceModel(makeRunner: { request, workingDirectory in + spy.record(request: request, workingDirectory: workingDirectory) + }) + return (workspace, spy) + } + + func testSubmitRecordsPromptStartsRunAndNeverOpensTerminal() throws { + let (workspace, spy) = makeWorkspace() workspace.prompt = "run A" - workspace.submit(to: terminal) + workspace.submit(workingDirectory: URL(fileURLWithPath: "/tmp/project")) XCTAssertEqual(workspace.runLifecycle, .running) XCTAssertFalse(workspace.isRunGateOpen) XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["run A"]) + XCTAssertEqual(workspace.prompt, "") + // The terminal is auxiliary: submitting must not present it. + XCTAssertFalse(workspace.isTerminalPresented) + XCTAssertEqual(try XCTUnwrap(spy.lastRunner).launchCount, 1) + XCTAssertEqual(spy.lastWorkingDirectory?.path, "/tmp/project") + } + + func testRunGateBlocksSecondSubmissionWhileAgentRuns() { + let (workspace, _) = makeWorkspace() + workspace.prompt = "run A" + workspace.submit(workingDirectory: nil) workspace.prompt = "run B" - workspace.submit(to: terminal) + workspace.submit(workingDirectory: nil) XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["run A"]) XCTAssertEqual(workspace.submissionError, "An agent run is already in progress.") } - func testRunReturnsToIdleWhenForegroundReturnsToShell() { - let probe = ForegroundStateProbe() - let workspace = AgentWorkspaceModel( - startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) - }, - isForegroundCommandRunning: { _ in probe.isRunning } - ) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) + func testStreamEventsRenderAssistantToolAndErrorItems() throws { + let (workspace, spy) = makeWorkspace() workspace.prompt = "run A" - workspace.submit(to: terminal) + workspace.submit(workingDirectory: nil) + let runner = try XCTUnwrap(spy.lastRunner) - // Startup race: the shell echoes the command line before it forks the - // agent, so foreground is still the shell — this must NOT end the run. - probe.isRunning = false - terminal.onOutputFlush?() - XCTAssertEqual(workspace.runLifecycle, .running) + runner.onEvent?(.assistantText("Looking at the code.")) + runner.onEvent?(.toolUse(name: "Edit", detail: "Sources/App/Main.swift")) + runner.onEvent?(.assistantText("Done.")) + runner.onCompletion?(.exited(0)) - // Agent takes the PTY foreground. - probe.isRunning = true - terminal.onOutputFlush?() - XCTAssertEqual(workspace.runLifecycle, .running) - - // Agent exits, the shell reclaims the foreground and redraws its prompt. - probe.isRunning = false - terminal.onOutputFlush?() XCTAssertEqual(workspace.runLifecycle, .idle) XCTAssertTrue(workspace.isRunGateOpen) + XCTAssertEqual(workspace.transcript.count, 4) + guard case let .prompt(prompt) = workspace.transcript[0], + case let .assistant(first) = workspace.transcript[1], + case let .tool(tool) = workspace.transcript[2], + case let .assistant(second) = workspace.transcript[3] else { + return XCTFail("Unexpected transcript shape: \(workspace.transcript)") + } + XCTAssertEqual(prompt.text, "run A") + XCTAssertEqual(first.text, "Looking at the code.") + XCTAssertEqual(tool.name, "Edit") + XCTAssertEqual(tool.detail, "Sources/App/Main.swift") + XCTAssertTrue(tool.isCodeChange) + XCTAssertEqual(second.text, "Done.") } - func testRunResolvesAfterStartupGraceWhenAgentNeverTakesForeground() { - let probe = ForegroundStateProbe() - probe.isRunning = false - let workspace = AgentWorkspaceModel( - startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) - }, - isForegroundCommandRunning: { _ in probe.isRunning }, - runStartupGrace: .zero - ) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) - workspace.prompt = "run missing-binary" - workspace.submit(to: terminal) - XCTAssertEqual(workspace.runLifecycle, .running) + func testConsecutiveAssistantTextMergesIntoOneMessage() throws { + let (workspace, spy) = makeWorkspace() + workspace.prompt = "run A" + workspace.submit(workingDirectory: nil) + let runner = try XCTUnwrap(spy.lastRunner) + + runner.onEvent?(.assistantText("First paragraph.")) + runner.onEvent?(.assistantText("Second paragraph.")) + + XCTAssertEqual(workspace.transcript.count, 2) + guard case let .assistant(message) = workspace.transcript[1] else { + return XCTFail("Expected one merged assistant message") + } + XCTAssertEqual(message.text, "First paragraph.\n\nSecond paragraph.") + } + + func testNonZeroExitAppendsErrorNoticeAndReopensGate() throws { + let (workspace, spy) = makeWorkspace() + workspace.prompt = "run A" + workspace.submit(workingDirectory: nil) + + try XCTUnwrap(spy.lastRunner).onCompletion?(.exited(127)) - // The binary never launches ("command not found"): foreground stays the - // shell on every flush. Past the startup grace the run must resolve - // instead of locking the composer forever. - terminal.onOutputFlush?() XCTAssertEqual(workspace.runLifecycle, .idle) - XCTAssertTrue(workspace.isRunGateOpen) + guard case let .notice(notice) = try XCTUnwrap(workspace.transcript.last) else { + return XCTFail("Expected an error notice") + } + XCTAssertTrue(notice.isError) + XCTAssertEqual(notice.text, "The agent exited with status 127.") } - func testRunStaysRunningWithinStartupGraceWhileForegroundIsStillShell() { - let probe = ForegroundStateProbe() - probe.isRunning = false - let workspace = AgentWorkspaceModel( - startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) - }, - isForegroundCommandRunning: { _ in probe.isRunning }, - runStartupGrace: .seconds(60) - ) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) + func testStreamErrorEventSuppressesGenericExitNotice() throws { + let (workspace, spy) = makeWorkspace() workspace.prompt = "run A" - workspace.submit(to: terminal) + workspace.submit(workingDirectory: nil) + let runner = try XCTUnwrap(spy.lastRunner) - // Within the grace window a shell-foreground flush is still the startup - // race, not a completion. - terminal.onOutputFlush?() - XCTAssertEqual(workspace.runLifecycle, .running) - XCTAssertFalse(workspace.isRunGateOpen) + runner.onEvent?(.completed(isError: true, summary: "Credit balance too low")) + runner.onCompletion?(.exited(1)) + + let notices = workspace.transcript.compactMap { item -> AgentRunNotice? in + if case let .notice(notice) = item { return notice } + return nil + } + XCTAssertEqual(notices.map(\.text), ["Credit balance too low"]) } - func testQueuedPromptStartsRunOnlyAfterDelivery() throws { - let probe = ForegroundStateProbe() - let workspace = AgentWorkspaceModel( - startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) - }, - isForegroundCommandRunning: { _ in probe.isRunning } - ) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) - workspace.prompt = "queued run" - workspace.submit(to: terminal) + func testLaunchFailureKeepsPromptAndReportsError() { + let spy = RunnerSpy() + spy.launchSucceeds = false + let workspace = AgentWorkspaceModel(makeRunner: { request, workingDirectory in + spy.record(request: request, workingDirectory: workingDirectory) + }) + workspace.provider = .claude + workspace.prompt = "run A" + + workspace.submit(workingDirectory: nil) XCTAssertEqual(workspace.runLifecycle, .idle) - XCTAssertFalse(workspace.isRunGateOpen) + XCTAssertTrue(workspace.transcript.isEmpty) + XCTAssertEqual(workspace.prompt, "run A") + XCTAssertEqual(workspace.submissionError, "The Claude CLI could not be launched.") + } - let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) - terminal.onInputDeliveryUpdate?(.delivered(token)) + func testCancelRunForwardsToActiveRunner() throws { + let (workspace, spy) = makeWorkspace() + workspace.prompt = "run A" + workspace.submit(workingDirectory: nil) - XCTAssertEqual(workspace.runLifecycle, .running) - XCTAssertFalse(workspace.isRunGateOpen) + workspace.cancelRun() + + XCTAssertEqual(try XCTUnwrap(spy.lastRunner).cancelCount, 1) } - func testSessionEndResetsRunToIdle() { - let probe = ForegroundStateProbe() - probe.isRunning = true - let workspace = AgentWorkspaceModel( - startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) - }, - isForegroundCommandRunning: { _ in probe.isRunning } - ) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) + /// Cancelling forwards to the runner but does not synchronously reopen the + /// gate: the runner is the source of truth and reports back via + /// `onCompletion` once the subprocess actually terminates. + func testCancelRunKeepsLifecycleRunningUntilRunnerReportsCompletion() throws { + let (workspace, spy) = makeWorkspace() workspace.prompt = "run A" - workspace.submit(to: terminal) + workspace.submit(workingDirectory: nil) + + workspace.cancelRun() + XCTAssertEqual(workspace.runLifecycle, .running) + XCTAssertFalse(workspace.isRunGateOpen) - terminal.onInputDeliveryUpdate?(.sessionEnded) + try XCTUnwrap(spy.lastRunner).onCompletion?(.terminated) XCTAssertEqual(workspace.runLifecycle, .idle) XCTAssertTrue(workspace.isRunGateOpen) } - func testDoesNotRecordPromptUntilLocalTerminalStarts() { - let workspace = AgentWorkspaceModel() - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) - workspace.prompt = "implement this" + /// Pins the `.terminated` outcome's notice text and error flag, since the + /// stop button relies on this to tell the user the run was cancelled. + func testTerminatedCompletionReopensGateAndAddsStoppedNotice() throws { + let (workspace, spy) = makeWorkspace() + workspace.prompt = "run A" + workspace.submit(workingDirectory: nil) - workspace.submit(to: terminal) + try XCTUnwrap(spy.lastRunner).onCompletion?(.terminated) - XCTAssertTrue(workspace.submittedPrompts.isEmpty) - XCTAssertEqual(workspace.submissionError, "The local terminal is still opening.") + XCTAssertEqual(workspace.runLifecycle, .idle) + XCTAssertTrue(workspace.isRunGateOpen) + guard case let .notice(notice) = try XCTUnwrap(workspace.transcript.last) else { + return XCTFail("Expected a stopped notice") + } + XCTAssertTrue(notice.isError) + XCTAssertEqual(notice.text, "The agent run was stopped.") } - func testQueuedPromptIsRecordedOnlyAfterTerminalDelivery() throws { - let workspace = AgentWorkspaceModel(startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) - }) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) - workspace.prompt = "wait for capacity" + func testRestoreTranscriptOnlyAppliesToFreshModel() { + let (workspace, _) = makeWorkspace() + let items: [AgentTranscriptItem] = [ + .prompt(AgentPrompt(provider: .claude, approval: .auto, text: "restored")), + .assistant(AgentAssistantMessage(provider: .claude, text: "reply")) + ] - workspace.submit(to: terminal) + workspace.restoreTranscript(items: items, provider: .claude, approval: .auto) - XCTAssertTrue(workspace.submittedPrompts.isEmpty) - XCTAssertEqual(workspace.pendingPrompt?.text, "wait for capacity") - XCTAssertTrue(workspace.isAwaitingPromptDelivery) - XCTAssertFalse(workspace.isComposerInteractionEnabled) + XCTAssertEqual(workspace.transcript.count, 2) + XCTAssertEqual(workspace.provider, .claude) + XCTAssertEqual(workspace.approval, .auto) - let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) - terminal.onInputDeliveryUpdate?(.delivered(token)) - - XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["wait for capacity"]) - XCTAssertNil(workspace.pendingPrompt) - XCTAssertFalse(workspace.isAwaitingPromptDelivery) - XCTAssertTrue(workspace.isComposerInteractionEnabled) + // A second restore must not clobber the live transcript. + workspace.restoreTranscript(items: [], provider: .codex, approval: .plan) + XCTAssertEqual(workspace.transcript.count, 2) } +} - func testQueuedPromptIsRestoredWhenTerminalRejectsItLater() throws { - let workspace = AgentWorkspaceModel(startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) - }) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) - workspace.prompt = "retry after failure" - - workspace.submit(to: terminal) - let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) - workspace.provider = .claude - workspace.approval = .auto - workspace.prompt = "new draft" - terminal.onInputDeliveryUpdate?(.failed(token, .systemCall("write", EIO))) - - XCTAssertTrue(workspace.submittedPrompts.isEmpty) - XCTAssertNil(workspace.pendingPrompt) - XCTAssertEqual(workspace.prompt, "retry after failure") - XCTAssertEqual(workspace.provider, .codex) - XCTAssertEqual(workspace.approval, .accept) - XCTAssertTrue(workspace.isComposerInteractionEnabled) - XCTAssertEqual(workspace.submissionError, "The queued prompt was not delivered.") +final class ComposerActionTests: XCTestCase { + func testIdleUsesSendModeReflectingCanSubmit() { + XCTAssertEqual(composerAction(for: .idle, canSubmit: true), .send(enabled: true)) + XCTAssertEqual(composerAction(for: .idle, canSubmit: false), .send(enabled: false)) } - func testStaleDeliveryCannotConfirmNewPendingPrompt() throws { - let workspace = AgentWorkspaceModel(startRequest: { _, _ in - TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) - }) - let terminal = TerminalSessionModel( - persistence: TerminalSessionPersistence(url: nil), - shell: "/bin/sh" - ) - workspace.prompt = "only the matching write may confirm this" - - workspace.submit(to: terminal) - terminal.onInputDeliveryUpdate?(.delivered(UUID())) + func testRunningAlwaysUsesStopModeRegardlessOfCanSubmit() { + // canSubmit is false in practice once a run starts, but stop mode must + // not depend on that: it is driven by runLifecycle alone. + XCTAssertEqual(composerAction(for: .running, canSubmit: false), .stop) + XCTAssertEqual(composerAction(for: .running, canSubmit: true), .stop) + } +} - XCTAssertTrue(workspace.submittedPrompts.isEmpty) - XCTAssertNotNil(workspace.pendingPrompt) +@MainActor +private final class RunnerSpy { + var launchSucceeds = true + private(set) var lastRunner: FakeAgentRunProcess? + private(set) var lastRequest: AgentRunRequest? + private(set) var lastWorkingDirectory: URL? + + func record(request: AgentRunRequest, workingDirectory: URL?) -> FakeAgentRunProcess { + let runner = FakeAgentRunProcess(launchSucceeds: launchSucceeds) + lastRunner = runner + lastRequest = request + lastWorkingDirectory = workingDirectory + return runner } } @MainActor -private final class ForegroundStateProbe { - var isRunning = false +private final class FakeAgentRunProcess: AgentRunProcess { + var onEvent: ((AgentStreamEvent) -> Void)? + var onCompletion: ((AgentRunOutcome) -> Void)? + private let launchSucceeds: Bool + private(set) var launchCount = 0 + private(set) var cancelCount = 0 + + init(launchSucceeds: Bool) { + self.launchSucceeds = launchSucceeds + } + + func launch() -> Bool { + launchCount += 1 + return launchSucceeds + } + + func cancel() { + cancelCount += 1 + } } diff --git a/Tests/MikuCodeAppTests/AgentWorkspaceLayoutTests.swift b/Tests/MikuCodeAppTests/AgentWorkspaceLayoutTests.swift new file mode 100644 index 0000000..0eb440b --- /dev/null +++ b/Tests/MikuCodeAppTests/AgentWorkspaceLayoutTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import MikuCodeApp + +final class AgentWorkspaceLayoutTests: XCTestCase { + func testResizedTerminalHeightClampsToPanelBounds() { + // Dragging up (negative translation) grows the panel to at most 60% of + // the workspace height. + XCTAssertEqual( + AgentWorkspaceLayout.resizedTerminalHeight(base: 200, dragTranslation: -1_000, workspaceHeight: 800), + 800 * 0.6 + ) + // Dragging down shrinks it, but the live height never collapses below + // the minimum — closing is a separate decision at drag end. + XCTAssertEqual( + AgentWorkspaceLayout.resizedTerminalHeight(base: 200, dragTranslation: 1_000, workspaceHeight: 800), + AgentWorkspaceLayout.terminalMinHeight + ) + XCTAssertEqual( + AgentWorkspaceLayout.resizedTerminalHeight(base: 200, dragTranslation: 40, workspaceHeight: 800), + 160 + ) + } + + func testTerminalDragClosesOnlyBelowThreshold() { + // 200 - 150 = 50 < 56: the drag ended under the close threshold. + XCTAssertTrue(AgentWorkspaceLayout.terminalDragShouldClose(base: 200, dragTranslation: 150)) + // 200 - 100 = 100: still an ordinary resize. + XCTAssertFalse(AgentWorkspaceLayout.terminalDragShouldClose(base: 200, dragTranslation: 100)) + // Dragging upward can never close. + XCTAssertFalse(AgentWorkspaceLayout.terminalDragShouldClose(base: 200, dragTranslation: -40)) + } +} diff --git a/Tests/MikuCodeAppTests/HeadlessAgentRunnerTests.swift b/Tests/MikuCodeAppTests/HeadlessAgentRunnerTests.swift new file mode 100644 index 0000000..cb6a5d0 --- /dev/null +++ b/Tests/MikuCodeAppTests/HeadlessAgentRunnerTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import MikuCodeApp + +/// Integration coverage for the real subprocess plumbing: pipes, chunked line +/// splitting, the termination flush of a trailing unterminated line, exit +/// status mapping, and cancellation. Fixtures are printed by /bin/zsh instead +/// of a real agent CLI. +@MainActor +final class HeadlessAgentRunnerTests: XCTestCase { + func testStreamsEventsAndCompletesOnExitZero() async throws { + // The final result line is printed WITHOUT a trailing newline so the + // termination flush path is exercised too. + let command = """ + printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}'; \ + printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"a.swift"}}]}}'; \ + printf '%s' '{"type":"result","is_error":false,"result":"done"}' + """ + let runner = HeadlessAgentRunner(command: command, workingDirectory: nil) + var events: [AgentStreamEvent] = [] + var outcome: AgentRunOutcome? + let completed = expectation(description: "run completes") + runner.onEvent = { events.append($0) } + runner.onCompletion = { outcome = $0; completed.fulfill() } + + XCTAssertTrue(runner.launch()) + await fulfillment(of: [completed], timeout: 10) + + XCTAssertEqual(outcome, .exited(0)) + XCTAssertEqual(events, [ + .assistantText("hello"), + .toolUse(name: "Edit", detail: "a.swift"), + .completed(isError: false, summary: "done") + ]) + } + + func testRunsInTheRequestedWorkingDirectoryAndMapsExitStatus() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("miku-runner-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let command = """ + printf '{"id":"0","msg":{"type":"agent_message","message":"cwd: %s"}}\\n' "$PWD"; exit 3 + """ + let runner = HeadlessAgentRunner(command: command, workingDirectory: directory) + var events: [AgentStreamEvent] = [] + var outcome: AgentRunOutcome? + let completed = expectation(description: "run completes") + runner.onEvent = { events.append($0) } + runner.onCompletion = { outcome = $0; completed.fulfill() } + + XCTAssertTrue(runner.launch()) + await fulfillment(of: [completed], timeout: 10) + + XCTAssertEqual(outcome, .exited(3)) + // The shell reports /private/var/… while the fixture URL may read + // /var/…; compare symlink-resolved paths instead of raw strings. + guard case let .assistantText(text)? = events.first, + text.hasPrefix("cwd: ") else { + return XCTFail("Expected a cwd assistant message, got \(events)") + } + XCTAssertEqual( + URL(fileURLWithPath: String(text.dropFirst("cwd: ".count))) + .resolvingSymlinksInPath().path, + directory.resolvingSymlinksInPath().path + ) + } + + func testCancelTerminatesTheRun() async throws { + let runner = HeadlessAgentRunner(command: "sleep 30", workingDirectory: nil) + var outcome: AgentRunOutcome? + let completed = expectation(description: "run completes") + runner.onCompletion = { outcome = $0; completed.fulfill() } + + XCTAssertTrue(runner.launch()) + runner.cancel() + await fulfillment(of: [completed], timeout: 10) + + XCTAssertEqual(outcome, .terminated) + } + + func testLaunchFailureReturnsFalse() { + // An unopenable working directory makes Process.run() throw. + let runner = HeadlessAgentRunner( + command: "true", + workingDirectory: URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)") + ) + + XCTAssertFalse(runner.launch()) + } +} diff --git a/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift b/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift index 9e81124..840ddf3 100644 --- a/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift +++ b/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift @@ -93,14 +93,18 @@ final class WorkspaceSessionStoreTests: XCTestCase { } func testSessionTitleTracksFirstSubmittedPromptLine() throws { - let terminal = makeTerminal() - terminal.start() - defer { terminal.stop() } - let store = makeStore(initialTerminal: terminal) + let store = makeStore(initialTerminal: makeTerminal()) let entry = try XCTUnwrap(store.selected) - entry.agent.prompt = "fix the login bug\nthen add tests" - entry.agent.submit(to: terminal) + entry.agent.restoreTranscript( + items: [.prompt(AgentPrompt( + provider: .codex, + approval: .accept, + text: "fix the login bug\nthen add tests" + ))], + provider: .codex, + approval: .accept + ) XCTAssertEqual(entry.title, "fix the login bug") } @@ -141,7 +145,7 @@ final class WorkspaceSessionStoreTests: XCTestCase { let store = makeStore(repository: repository) let entry = store.createSession() entry.agent.restoreTranscript( - prompts: [AgentPrompt(provider: .codex, approval: .accept, text: "fix the build")], + items: [.prompt(AgentPrompt(provider: .codex, approval: .accept, text: "fix the build"))], provider: .codex, approval: .accept ) @@ -200,10 +204,12 @@ final class WorkspaceSessionStoreTests: XCTestCase { let store = makeStore(repository: repository) let first = store.createSession(workingDirectory: workFolder) - first.terminal.start() - first.agent.prompt = "build the sidebar" - first.agent.submit(to: first.terminal) - first.terminal.stop() + first.agent.restoreTranscript( + items: [.prompt(AgentPrompt(provider: .codex, approval: .accept, text: "build the sidebar"))], + provider: .codex, + approval: .accept + ) + store.persist() let second = store.createSession() store.select(first.id) @@ -220,6 +226,115 @@ final class WorkspaceSessionStoreTests: XCTestCase { XCTAssertFalse(restored.sessions[0].terminal.isRunning) } + func testDefaultWorkingDirectoryFallsBackToDesktop() { + let store = makeStore() + + XCTAssertEqual(store.defaultWorkingDirectory?.lastPathComponent, "Desktop") + + // Threads created without an explicit folder inherit the Desktop + // default instead of running nowhere. + let entry = store.createSession() + XCTAssertEqual(entry.workingDirectory?.lastPathComponent, "Desktop") + } + + func testDeleteSessionCancelsItsActiveAgentRun() throws { + let spy = StoreRunnerSpy() + let store = WorkspaceSessionStore( + repository: WorkspaceSessionsRepository(directoryURL: nil), + shell: "/bin/sh", + makeAgent: { AgentWorkspaceModel(makeRunner: { _, _ in spy.makeRunner() }) } + ) + let entry = store.createSession() + entry.agent.prompt = "long run" + entry.agent.submit(workingDirectory: nil) + XCTAssertEqual(entry.agent.runLifecycle, .running) + + store.deleteSession(entry.id) + + XCTAssertEqual(spy.cancelCount, 1) + } + + func testStopCancelsActiveAgentRunsInAllSessions() throws { + let spy = StoreRunnerSpy() + let store = WorkspaceSessionStore( + repository: WorkspaceSessionsRepository(directoryURL: nil), + shell: "/bin/sh", + makeAgent: { AgentWorkspaceModel(makeRunner: { _, _ in spy.makeRunner() }) } + ) + let entry = store.createSession() + entry.agent.prompt = "long run" + entry.agent.submit(workingDirectory: nil) + + store.stop() + + XCTAssertEqual(spy.cancelCount, 1) + } + + func testFullTranscriptRoundTripsAcrossStoreInstances() throws { + let directory = try makeTemporaryRepositoryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = WorkspaceSessionsRepository(directoryURL: directory) + + let store = makeStore(repository: repository) + let entry = store.createSession() + entry.agent.restoreTranscript( + items: [ + .prompt(AgentPrompt(provider: .claude, approval: .auto, text: "add a login page")), + .assistant(AgentAssistantMessage(provider: .claude, text: "Starting with the form.")), + .tool(AgentToolEvent(name: "Edit", detail: "Sources/App/Login.swift")), + .notice(AgentRunNotice(text: "The agent exited with status 1.", isError: true)) + ], + provider: .claude, + approval: .auto + ) + store.persist() + + let restored = makeStore(repository: repository) + let transcript = try XCTUnwrap(restored.selected?.agent.transcript) + XCTAssertEqual(transcript.count, 4) + guard case let .prompt(prompt) = transcript[0], + case let .assistant(message) = transcript[1], + case let .tool(tool) = transcript[2], + case let .notice(notice) = transcript[3] else { + return XCTFail("Unexpected restored transcript shape") + } + XCTAssertEqual(prompt.text, "add a login page") + XCTAssertEqual(prompt.provider, .claude) + XCTAssertEqual(prompt.approval, .auto) + XCTAssertEqual(message.text, "Starting with the form.") + XCTAssertEqual(message.provider, .claude) + XCTAssertEqual(tool.name, "Edit") + XCTAssertEqual(tool.detail, "Sources/App/Login.swift") + XCTAssertEqual(notice.text, "The agent exited with status 1.") + XCTAssertTrue(notice.isError) + XCTAssertEqual(restored.selected?.agent.provider, .claude) + XCTAssertEqual(restored.selected?.agent.approval, .auto) + } + + @MainActor + private final class StoreRunnerSpy { + private(set) var cancelCount = 0 + + func makeRunner() -> AgentRunProcess { + let runner = HangingRunProcess() + runner.onCancel = { [weak self] in self?.cancelCount += 1 } + return runner + } + } + + @MainActor + private final class HangingRunProcess: AgentRunProcess { + var onEvent: ((AgentStreamEvent) -> Void)? + var onCompletion: ((AgentRunOutcome) -> Void)? + var onCancel: (() -> Void)? + + func launch() -> Bool { true } + + func cancel() { + onCancel?() + } + } + func testRestoredThreadKeepsItsTerminalScrollback() async throws { let directory = try makeTemporaryRepositoryDirectory() defer { try? FileManager.default.removeItem(at: directory) }