From e680c5b77216680fda8733e607329695c4607921 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 17:35:39 +0900 Subject: [PATCH 01/10] feat: add coding agent workspace Signed-off-by: sionic-khope --- Sources/MikuCodeApp/AgentWorkspaceView.swift | 329 ++++++++++++++++++ .../MikuCodeApp/Application/MikuCodeApp.swift | 41 +-- .../AgentRunRequestTests.swift | 37 ++ .../MikuCodeAppTests/SourceSafetyTests.swift | 7 +- 4 files changed, 378 insertions(+), 36 deletions(-) create mode 100644 Sources/MikuCodeApp/AgentWorkspaceView.swift create mode 100644 Tests/MikuCodeAppTests/AgentRunRequestTests.swift diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift new file mode 100644 index 0000000..f13b379 --- /dev/null +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -0,0 +1,329 @@ +import SwiftUI + +@MainActor +struct AgentWorkspaceView: View { + @ObservedObject var terminalSession: TerminalSessionModel + let terminalFocusRequestID: Int + let onTerminalFocus: () -> Void + let onClose: () -> Void + + @State private var provider = AgentProvider.codex + @State private var approval = AgentApproval.accept + @State private var prompt = "" + @State private var submittedPrompts: [AgentPrompt] = [] + @State private var isTerminalPresented = false + @FocusState private var isPromptFocused: Bool + + var body: some View { + VStack(spacing: 0) { + header + conversation + composer + if isTerminalPresented { + embeddedTerminal + } + } + .background(AgentWorkspacePalette.surface) + .clipShape(SpeechBubbleShape()) + .overlay { + SpeechBubbleShape() + .stroke(AgentWorkspacePalette.rim, lineWidth: 1) + } + .shadow(color: .black.opacity(0.26), radius: 20, x: 0, y: 10) + .onAppear { + isPromptFocused = true + } + .accessibilityElement(children: .contain) + .accessibilityLabel("MikuCode coding agent workspace") + } + + private var header: some View { + HStack(spacing: 12) { + Text("miku-code") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + Spacer() + Button { + withAnimation(.easeOut(duration: 0.16)) { + isTerminalPresented.toggle() + } + } label: { + Image(systemName: "terminal") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .foregroundStyle(isTerminalPresented ? AgentWorkspacePalette.teal : AgentWorkspacePalette.muted) + .background(AgentWorkspacePalette.control) + .clipShape(Circle()) + .accessibilityLabel(isTerminalPresented ? "Hide terminal" : "Show terminal") + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .foregroundStyle(AgentWorkspacePalette.muted) + .background(AgentWorkspacePalette.control) + .clipShape(Circle()) + .accessibilityLabel("Return Miku to desktop") + } + .padding(.leading, 20) + .padding(.trailing, 52) + .frame(height: 46) + .background(AgentWorkspacePalette.header) + .overlay(alignment: .bottom) { + Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) + } + } + + private var conversation: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if submittedPrompts.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("What are we building?") + .font(.system(size: 21, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + Text("Choose an agent, set its permission mode, then describe the task.") + .font(.system(size: 13, design: .rounded)) + .foregroundStyle(AgentWorkspacePalette.muted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 44) + } else { + ForEach(submittedPrompts) { submittedPrompt in + VStack(alignment: .leading, spacing: 8) { + Text("you · \(submittedPrompt.provider.title) · \(submittedPrompt.approval.title)") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(AgentWorkspacePalette.teal) + Text(submittedPrompt.text) + .font(.system(size: 14, design: .rounded)) + .foregroundStyle(.white) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentWorkspacePalette.message) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + HStack(spacing: 8) { + Circle().fill(AgentWorkspacePalette.teal).frame(width: 6, height: 6) + Text("Running in the integrated terminal") + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(AgentWorkspacePalette.muted) + } + } + } + .frame(maxWidth: 620, alignment: .leading) + .padding(.horizontal, 28) + .padding(.bottom, 28) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Menu { + ForEach(AgentProvider.allCases) { option in + Button(option.title) { provider = option } + } + } label: { + AgentWorkspacePill(title: provider.title) + } + .menuStyle(.borderlessButton) + + Menu { + ForEach(AgentApproval.allCases) { option in + Button(option.title) { approval = option } + } + } label: { + AgentWorkspacePill(title: approval.title) + } + .menuStyle(.borderlessButton) + Spacer() + } + + HStack(alignment: .bottom, spacing: 10) { + TextField("Ask the coding agent…", text: $prompt, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 14, design: .rounded)) + .foregroundStyle(.white) + .focused($isPromptFocused) + .lineLimit(1...5) + .onSubmit(submit) + + Button(action: submit) { + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .bold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .foregroundStyle(.black) + .background(AgentWorkspacePalette.teal) + .clipShape(Circle()) + .disabled(trimmedPrompt.isEmpty) + .opacity(trimmedPrompt.isEmpty ? 0.38 : 1) + .accessibilityLabel("Start coding session") + } + .padding(12) + .background(AgentWorkspacePalette.composer) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(AgentWorkspacePalette.rim, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(20) + .background(AgentWorkspacePalette.header) + .overlay(alignment: .top) { + Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) + } + } + + private var embeddedTerminal: some View { + TerminalPanel( + session: terminalSession, + focusRequestID: terminalFocusRequestID, + onFocus: onTerminalFocus, + onClose: {}, + onResize: { _, _ in }, + onResizeEnded: {}, + showsChrome: false, + showsResizeHandles: false + ) + .frame(height: 260) + .background(Color.black.opacity(0.34)) + .overlay(alignment: .top) { + Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onTapGesture(perform: onTerminalFocus) + } + + private var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func submit() { + guard !trimmedPrompt.isEmpty else { return } + let request = AgentRunRequest( + provider: provider, + approval: approval, + prompt: trimmedPrompt + ) + submittedPrompts.append(AgentPrompt( + provider: provider, + approval: approval, + text: trimmedPrompt + )) + prompt = "" + isTerminalPresented = true + onTerminalFocus() + terminalSession.submit(request.shellCommand) + } +} + +private struct AgentWorkspacePill: View { + let title: String + + var body: some View { + HStack(spacing: 5) { + Text(title) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(AgentWorkspacePalette.muted) + .padding(.horizontal, 9) + .frame(height: 26) + .background(AgentWorkspacePalette.control) + .clipShape(Capsule()) + } +} + +private enum AgentWorkspacePalette { + static let surface = Color(red: 0.045, green: 0.060, blue: 0.090) + static let header = Color(red: 0.060, green: 0.078, blue: 0.112) + static let composer = Color(red: 0.075, green: 0.098, blue: 0.136) + static let message = Color.white.opacity(0.065) + static let control = Color.white.opacity(0.075) + static let rim = Color.white.opacity(0.12) + static let muted = Color.white.opacity(0.56) + static let teal = Color(red: 0.35, green: 0.88, blue: 0.86) +} + +enum AgentProvider: String, CaseIterable, Identifiable { + case codex + case claude + case pi + case openCode + + var id: Self { self } + + var title: String { + switch self { + case .codex: "Codex" + case .claude: "Claude" + case .pi: "Pi" + case .openCode: "OpenCode" + } + } +} + +enum AgentApproval: String, CaseIterable, Identifiable { + case plan + case accept + case auto + + var id: Self { self } + + var title: String { + switch self { + case .plan: "Plan" + case .accept: "Accept" + case .auto: "Auto" + } + } +} + +struct AgentRunRequest: Equatable { + let provider: AgentProvider + let approval: AgentApproval + let prompt: String + + var shellCommand: String { + switch provider { + case .codex: + let sandbox = switch approval { + case .plan: "read-only" + case .accept: "workspace-write" + case .auto: "danger-full-access" + } + return "codex exec --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))" + case .pi: + return "pi \(Self.quote(prompt))" + case .openCode: + return "opencode run \(Self.quote(prompt))" + } + } + + private static func quote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } +} + +private struct AgentPrompt: Identifiable { + let id = UUID() + let provider: AgentProvider + let approval: AgentApproval + let text: String +} diff --git a/Sources/MikuCodeApp/Application/MikuCodeApp.swift b/Sources/MikuCodeApp/Application/MikuCodeApp.swift index f9b3b3b..eaccfaf 100644 --- a/Sources/MikuCodeApp/Application/MikuCodeApp.swift +++ b/Sources/MikuCodeApp/Application/MikuCodeApp.swift @@ -154,41 +154,14 @@ struct MikuRootView: View { return ZStack(alignment: .topLeading) { MikuVisualTokens.activeVeil .accessibilityHidden(true) - Group { - if let workspace { - TerminalWorkspaceView( - workspace: workspace, - focusRequestID: presentation.focusRequestID, - onFocus: presentation.requestTerminalFocus, - onClose: onClose, - onResize: { edge, translation in - resizeTerminal(edge: edge, translation: translation, displaySize: displaySize) - }, - onResizeEnded: { resizeOrigin = nil }, - rendererPolicy: terminalRendererPolicy - ) - } else { - TerminalPanel( - session: terminalSession, - focusRequestID: presentation.focusRequestID, - onFocus: presentation.requestTerminalFocus, - onClose: onClose, - onResize: { edge, translation in - resizeTerminal(edge: edge, translation: translation, displaySize: displaySize) - }, - onResizeEnded: { resizeOrigin = nil }, - rendererPolicy: terminalRendererPolicy - ) - } - } - .frame( - width: terminalFrame.width, - height: terminalFrame.height - ) - .position( - x: terminalFrame.midX, - y: terminalFrame.midY + AgentWorkspaceView( + terminalSession: terminalSession, + terminalFocusRequestID: presentation.focusRequestID, + onTerminalFocus: presentation.requestTerminalFocus, + onClose: onClose ) + .frame(width: terminalFrame.width, height: terminalFrame.height) + .position(x: terminalFrame.midX, y: terminalFrame.midY) Button(action: onClose) { ActiveMikuCloseTarget(isHovering: isActiveMikuHovering) } diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift new file mode 100644 index 0000000..3c0052e --- /dev/null +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import MikuCodeApp + +final class AgentRunRequestTests: XCTestCase { + func testCodexCommandUsesApprovalSandbox() { + let plan = AgentRunRequest(provider: .codex, approval: .plan, prompt: "inspect") + let accept = AgentRunRequest(provider: .codex, approval: .accept, prompt: "edit") + let auto = 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'") + } + + func testClaudeCommandUsesMatchingPermissionMode() { + let request = AgentRunRequest( + provider: .claude, + approval: .accept, + prompt: "make the change" + ) + + XCTAssertEqual( + request.shellCommand, + "claude --permission-mode acceptEdits 'make the change'" + ) + } + + func testCommandQuotesSingleQuotesInPrompt() { + let request = AgentRunRequest( + provider: .openCode, + approval: .plan, + prompt: "fix user's command" + ) + + XCTAssertEqual(request.shellCommand, "opencode run 'fix user'\"'\"'s command'") + } +} diff --git a/Tests/MikuCodeAppTests/SourceSafetyTests.swift b/Tests/MikuCodeAppTests/SourceSafetyTests.swift index 8bc6f0a..cc259f3 100644 --- a/Tests/MikuCodeAppTests/SourceSafetyTests.swift +++ b/Tests/MikuCodeAppTests/SourceSafetyTests.swift @@ -53,10 +53,13 @@ final class SourceSafetyTests: XCTestCase { XCTAssertEqual(combined.components(separatedBy: "MikuPanel(").count - 1, 1) } - func testTerminalFindFieldIsOnlyUsedForTheIntegratedSearchSurface() throws { + func testTextFieldsAreLimitedToTheAgentComposerAndTerminalSearch() throws { let fieldSources = try sourceFiles().filter { $0.contents.contains("Text" + "Field(") } - XCTAssertEqual(fieldSources.map(\.url.lastPathComponent), ["TerminalSearchOverlay.swift"]) + XCTAssertEqual( + Set(fieldSources.map(\.url.lastPathComponent)), + ["AgentWorkspaceView.swift", "TerminalSearchOverlay.swift"] + ) } private func sourceFiles() throws -> [(url: URL, contents: String)] { From df92f38a1352ec27c5de7131fb85d91b4f4c292d Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 17:50:11 +0900 Subject: [PATCH 02/10] fix: keep coding desktop session ownership coherent Signed-off-by: sionic-khope --- .../Agent/AgentWorkspaceModel.swift | 130 ++++++++++++++ Sources/MikuCodeApp/AgentWorkspaceView.swift | 169 +++++------------- .../MikuCodeApp/Application/MikuCodeApp.swift | 14 +- .../Companion/MikuPanelCoordinator.swift | 53 +----- .../AgentRunRequestTests.swift | 42 +++-- .../AppShellPolicyTests.swift | 15 ++ 6 files changed, 230 insertions(+), 193 deletions(-) create mode 100644 Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift new file mode 100644 index 0000000..46e78d1 --- /dev/null +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -0,0 +1,130 @@ +import Foundation +import SwiftUI + +enum AgentProvider: String, CaseIterable, Identifiable { + case codex + case claude + case pi + case openCode + + var id: Self { self } + + var title: String { + switch self { + case .codex: "Codex" + case .claude: "Claude" + case .pi: "Pi" + case .openCode: "OpenCode" + } + } + + var supportsApproval: Bool { + self == .codex || self == .claude + } +} + +enum AgentApproval: String, CaseIterable, Identifiable { + case plan + case accept + case auto + + var id: Self { self } + + var title: String { + switch self { + case .plan: "Plan" + case .accept: "Accept" + case .auto: "Auto" + } + } +} + +struct AgentRunRequest: Equatable { + let provider: AgentProvider + let approval: AgentApproval + let prompt: String + + init?(provider: AgentProvider, approval: AgentApproval, prompt: String) { + guard provider.supportsApproval else { return nil } + guard !prompt.isEmpty else { return nil } + guard prompt.unicodeScalars.allSatisfy({ scalar in + scalar.value >= 0x20 && scalar.value != 0x7F + }) else { + return nil + } + self.provider = provider + self.approval = approval + self.prompt = prompt + } + + var shellCommand: String { + switch provider { + case .codex: + let sandbox = switch approval { + case .plan: "read-only" + case .accept: "workspace-write" + case .auto: "danger-full-access" + } + return "codex exec --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))" + case .pi, .openCode: + preconditionFailure("Unsupported provider cannot create a run request.") + } + } + + private static func quote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } +} + +@MainActor +struct LocalPTYAgentRunner { + let terminalSession: TerminalSessionModel + + func start(_ request: AgentRunRequest) -> Bool { + guard terminalSession.isRunning else { return false } + terminalSession.submit(request.shellCommand) + return true + } +} + +@MainActor +final class AgentWorkspaceModel: ObservableObject { + @Published var provider = AgentProvider.codex + @Published var approval = AgentApproval.accept + @Published var prompt = "" + @Published private(set) var submittedPrompts: [AgentPrompt] = [] + @Published var isTerminalPresented = false + @Published private(set) var submissionError: String? + + func submit(to terminalSession: TerminalSessionModel) { + 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." + : "This provider is not available in the current workspace." + return + } + guard LocalPTYAgentRunner(terminalSession: terminalSession).start(request) else { + submissionError = "The local terminal is still opening." + return + } + submittedPrompts.append(AgentPrompt(provider: provider, approval: approval, text: text)) + prompt = "" + submissionError = nil + isTerminalPresented = true + } +} + +struct AgentPrompt: Identifiable { + let id = UUID() + let provider: AgentProvider + let approval: AgentApproval + let text: String +} diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index f13b379..4eaacf3 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -3,15 +3,13 @@ import SwiftUI @MainActor struct AgentWorkspaceView: View { @ObservedObject var terminalSession: TerminalSessionModel + @ObservedObject var workspace: AgentWorkspaceModel let terminalFocusRequestID: Int let onTerminalFocus: () -> Void let onClose: () -> Void - - @State private var provider = AgentProvider.codex - @State private var approval = AgentApproval.accept - @State private var prompt = "" - @State private var submittedPrompts: [AgentPrompt] = [] - @State private var isTerminalPresented = false + let onResize: (TerminalBubbleResizeEdge, CGSize) -> Void + let onResizeEnded: () -> Void + let rendererPolicy: TerminalRendererPolicy @FocusState private var isPromptFocused: Bool var body: some View { @@ -19,20 +17,18 @@ struct AgentWorkspaceView: View { header conversation composer - if isTerminalPresented { + if workspace.isTerminalPresented { embeddedTerminal } } .background(AgentWorkspacePalette.surface) .clipShape(SpeechBubbleShape()) + .overlay { SpeechBubbleShape().stroke(AgentWorkspacePalette.rim, lineWidth: 1) } .overlay { - SpeechBubbleShape() - .stroke(AgentWorkspacePalette.rim, lineWidth: 1) + TerminalResizeHandles(onResize: onResize, onResizeEnded: onResizeEnded) } .shadow(color: .black.opacity(0.26), radius: 20, x: 0, y: 10) - .onAppear { - isPromptFocused = true - } + .onAppear { isPromptFocused = true } .accessibilityElement(children: .contain) .accessibilityLabel("MikuCode coding agent workspace") } @@ -45,18 +41,19 @@ struct AgentWorkspaceView: View { Spacer() Button { withAnimation(.easeOut(duration: 0.16)) { - isTerminalPresented.toggle() + workspace.isTerminalPresented.toggle() } + if workspace.isTerminalPresented { onTerminalFocus() } } label: { Image(systemName: "terminal") .font(.system(size: 12, weight: .semibold)) .frame(width: 28, height: 28) } .buttonStyle(.plain) - .foregroundStyle(isTerminalPresented ? AgentWorkspacePalette.teal : AgentWorkspacePalette.muted) + .foregroundStyle(workspace.isTerminalPresented ? AgentWorkspacePalette.teal : AgentWorkspacePalette.muted) .background(AgentWorkspacePalette.control) .clipShape(Circle()) - .accessibilityLabel(isTerminalPresented ? "Hide terminal" : "Show terminal") + .accessibilityLabel(workspace.isTerminalPresented ? "Hide terminal" : "Show terminal") Button(action: onClose) { Image(systemName: "xmark") @@ -73,15 +70,13 @@ struct AgentWorkspaceView: View { .padding(.trailing, 52) .frame(height: 46) .background(AgentWorkspacePalette.header) - .overlay(alignment: .bottom) { - Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) - } + .overlay(alignment: .bottom) { Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) } } private var conversation: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { - if submittedPrompts.isEmpty { + if workspace.submittedPrompts.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("What are we building?") .font(.system(size: 21, weight: .semibold, design: .rounded)) @@ -93,12 +88,12 @@ struct AgentWorkspaceView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 44) } else { - ForEach(submittedPrompts) { submittedPrompt in + ForEach(workspace.submittedPrompts) { prompt in VStack(alignment: .leading, spacing: 8) { - Text("you · \(submittedPrompt.provider.title) · \(submittedPrompt.approval.title)") + Text("you · \(prompt.provider.title) · \(prompt.approval.title)") .font(.system(size: 10, weight: .semibold, design: .monospaced)) .foregroundStyle(AgentWorkspacePalette.teal) - Text(submittedPrompt.text) + Text(prompt.text) .font(.system(size: 14, design: .rounded)) .foregroundStyle(.white) } @@ -114,6 +109,11 @@ struct AgentWorkspaceView: View { .foregroundStyle(AgentWorkspacePalette.muted) } } + if let error = workspace.submissionError { + Text(error) + .font(.system(size: 11, design: .rounded)) + .foregroundStyle(Color.red.opacity(0.92)) + } } .frame(maxWidth: 620, alignment: .leading) .padding(.horizontal, 28) @@ -127,26 +127,27 @@ struct AgentWorkspaceView: View { HStack(spacing: 8) { Menu { ForEach(AgentProvider.allCases) { option in - Button(option.title) { provider = option } + Button(option.title) { workspace.provider = option } + .disabled(!option.supportsApproval) } } label: { - AgentWorkspacePill(title: provider.title) + AgentWorkspacePill(title: workspace.provider.title) } .menuStyle(.borderlessButton) Menu { ForEach(AgentApproval.allCases) { option in - Button(option.title) { approval = option } + Button(option.title) { workspace.approval = option } } } label: { - AgentWorkspacePill(title: approval.title) + AgentWorkspacePill(title: workspace.approval.title) } .menuStyle(.borderlessButton) Spacer() } HStack(alignment: .bottom, spacing: 10) { - TextField("Ask the coding agent…", text: $prompt, axis: .vertical) + TextField("Ask the coding agent…", text: $workspace.prompt, axis: .vertical) .textFieldStyle(.plain) .font(.system(size: 14, design: .rounded)) .foregroundStyle(.white) @@ -163,23 +164,18 @@ struct AgentWorkspaceView: View { .foregroundStyle(.black) .background(AgentWorkspacePalette.teal) .clipShape(Circle()) - .disabled(trimmedPrompt.isEmpty) - .opacity(trimmedPrompt.isEmpty ? 0.38 : 1) + .disabled(!canSubmit) + .opacity(canSubmit ? 1 : 0.38) .accessibilityLabel("Start coding session") } .padding(12) .background(AgentWorkspacePalette.composer) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(AgentWorkspacePalette.rim, lineWidth: 1) - } + .overlay { RoundedRectangle(cornerRadius: 12).stroke(AgentWorkspacePalette.rim, lineWidth: 1) } .clipShape(RoundedRectangle(cornerRadius: 12)) } .padding(20) .background(AgentWorkspacePalette.header) - .overlay(alignment: .top) { - Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) - } + .overlay(alignment: .top) { Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) } } private var embeddedTerminal: some View { @@ -190,38 +186,27 @@ struct AgentWorkspaceView: View { onClose: {}, onResize: { _, _ in }, onResizeEnded: {}, + rendererPolicy: rendererPolicy, showsChrome: false, showsResizeHandles: false ) .frame(height: 260) .background(Color.black.opacity(0.34)) - .overlay(alignment: .top) { - Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) - } + .overlay(alignment: .top) { Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) } .transition(.move(edge: .bottom).combined(with: .opacity)) .onTapGesture(perform: onTerminalFocus) } - private var trimmedPrompt: String { - prompt.trimmingCharacters(in: .whitespacesAndNewlines) + private var canSubmit: Bool { + terminalSession.isRunning + && workspace.provider.supportsApproval + && !workspace.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func submit() { - guard !trimmedPrompt.isEmpty else { return } - let request = AgentRunRequest( - provider: provider, - approval: approval, - prompt: trimmedPrompt - ) - submittedPrompts.append(AgentPrompt( - provider: provider, - approval: approval, - text: trimmedPrompt - )) - prompt = "" - isTerminalPresented = true + guard canSubmit else { return } + workspace.submit(to: terminalSession) onTerminalFocus() - terminalSession.submit(request.shellCommand) } } @@ -253,77 +238,3 @@ private enum AgentWorkspacePalette { static let muted = Color.white.opacity(0.56) static let teal = Color(red: 0.35, green: 0.88, blue: 0.86) } - -enum AgentProvider: String, CaseIterable, Identifiable { - case codex - case claude - case pi - case openCode - - var id: Self { self } - - var title: String { - switch self { - case .codex: "Codex" - case .claude: "Claude" - case .pi: "Pi" - case .openCode: "OpenCode" - } - } -} - -enum AgentApproval: String, CaseIterable, Identifiable { - case plan - case accept - case auto - - var id: Self { self } - - var title: String { - switch self { - case .plan: "Plan" - case .accept: "Accept" - case .auto: "Auto" - } - } -} - -struct AgentRunRequest: Equatable { - let provider: AgentProvider - let approval: AgentApproval - let prompt: String - - var shellCommand: String { - switch provider { - case .codex: - let sandbox = switch approval { - case .plan: "read-only" - case .accept: "workspace-write" - case .auto: "danger-full-access" - } - return "codex exec --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))" - case .pi: - return "pi \(Self.quote(prompt))" - case .openCode: - return "opencode run \(Self.quote(prompt))" - } - } - - private static func quote(_ value: String) -> String { - "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" - } -} - -private struct AgentPrompt: Identifiable { - let id = UUID() - let provider: AgentProvider - let approval: AgentApproval - let text: String -} diff --git a/Sources/MikuCodeApp/Application/MikuCodeApp.swift b/Sources/MikuCodeApp/Application/MikuCodeApp.swift index eaccfaf..b915a05 100644 --- a/Sources/MikuCodeApp/Application/MikuCodeApp.swift +++ b/Sources/MikuCodeApp/Application/MikuCodeApp.swift @@ -72,7 +72,7 @@ final class MikuApplicationDelegate: NSObject, NSApplicationDelegate { struct MikuRootView: View { @ObservedObject var presentation: AppPresentationStore @ObservedObject var terminalSession: TerminalSessionModel - let workspace: TerminalWorkspaceModel? + @ObservedObject var agentWorkspace: AgentWorkspaceModel @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var resizedTerminalFrame: CGRect? @State private var resizeOrigin: CGRect? @@ -88,7 +88,7 @@ struct MikuRootView: View { init( presentation: AppPresentationStore, terminalSession: TerminalSessionModel, - workspace: TerminalWorkspaceModel? = nil, + agentWorkspace: AgentWorkspaceModel = AgentWorkspaceModel(), onOpen: @escaping () -> Void, onOpeningComplete: @escaping () -> Void, onClose: @escaping () -> Void, @@ -98,7 +98,7 @@ struct MikuRootView: View { ) { self.presentation = presentation self.terminalSession = terminalSession - self.workspace = workspace + self.agentWorkspace = agentWorkspace self.onOpen = onOpen self.onOpeningComplete = onOpeningComplete self.onClose = onClose @@ -156,9 +156,15 @@ struct MikuRootView: View { .accessibilityHidden(true) AgentWorkspaceView( terminalSession: terminalSession, + workspace: agentWorkspace, terminalFocusRequestID: presentation.focusRequestID, onTerminalFocus: presentation.requestTerminalFocus, - onClose: onClose + onClose: onClose, + onResize: { edge, translation in + resizeTerminal(edge: edge, translation: translation, displaySize: displaySize) + }, + onResizeEnded: { resizeOrigin = nil }, + rendererPolicy: terminalRendererPolicy ) .frame(width: terminalFrame.width, height: terminalFrame.height) .position(x: terminalFrame.midX, y: terminalFrame.midY) diff --git a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift index bcb89a7..8f7d812 100644 --- a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift +++ b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift @@ -7,7 +7,7 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { let panel: MikuPanel private let terminalSession: TerminalSessionModel - private let terminalWorkspace: TerminalWorkspaceModel + private let agentWorkspace = AgentWorkspaceModel() private let presentation: AppPresentationStore private let hitTestView: OverlayHitTestView private let ordersFront: Bool @@ -21,21 +21,10 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { ordersFront: Bool = true ) { self.screenFrame = screenFrame - let workspace: TerminalWorkspaceModel - if let terminalWorkspace { - workspace = terminalWorkspace - } else if let terminalSession { - workspace = TerminalWorkspaceModel( - sessionFactory: InitialTerminalWorkspaceSessionFactory(initial: terminalSession) - ) - } else { - workspace = TerminalWorkspaceModel() - } - self.terminalWorkspace = workspace self.terminalSession = terminalSession - ?? (workspace.focusedSession as? TerminalSessionModel) + ?? (terminalWorkspace?.focusedSession as? TerminalSessionModel) ?? TerminalSessionModel() - presentation = AppPresentationStore(terminalLifecycle: self.terminalWorkspace) + presentation = AppPresentationStore(terminalLifecycle: self.terminalSession) self.ordersFront = ordersFront panel = MikuPanel( contentRect: screenFrame, @@ -118,15 +107,6 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { private func configurePanelBase() { panel.delegate = self panel.onCloseRequest = { [weak self] in self?.requestClose() } - panel.onSplitRight = { [weak self] in - _ = self?.terminalWorkspace.splitRight() - } - panel.onSplitBottom = { [weak self] in - _ = self?.terminalWorkspace.splitBottom() - } - panel.onNewTab = { [weak self] in - _ = self?.terminalWorkspace.createTab() - } panel.onIdleEntryClick = { [weak self] point in self?.openIdleEntry(at: point) } @@ -151,7 +131,7 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { let root = MikuRootView( presentation: presentation, terminalSession: terminalSession, - workspace: terminalWorkspace, + agentWorkspace: agentWorkspace, onOpen: { [weak self] in self?.requestOpen() }, onOpeningComplete: { [weak self] in self?.completeOpening() }, onClose: { [weak self] in self?.requestClose() }, @@ -213,31 +193,6 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { ) } -@MainActor -private final class InitialTerminalWorkspaceSessionFactory: TerminalWorkspaceSessionFactory { - private var initial: TerminalSessionModel? - - init(initial: TerminalSessionModel) { - self.initial = initial - } - - func makeSession() -> any TerminalWorkspaceSession { - if let initial { - self.initial = nil - return initial - } - return TerminalSessionModel() - } - - func makeSession(persistence: TerminalSessionPersistence) -> any TerminalWorkspaceSession { - if let initial { - self.initial = nil - return initial - } - return TerminalSessionModel(persistence: persistence) - } -} - final class MikuPanel: NSPanel { var isTerminalPresented = false var onCloseRequest: (() -> Void)? diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift index 3c0052e..97b4c77 100644 --- a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -1,23 +1,24 @@ import XCTest @testable import MikuCodeApp +@MainActor final class AgentRunRequestTests: XCTestCase { - func testCodexCommandUsesApprovalSandbox() { - let plan = AgentRunRequest(provider: .codex, approval: .plan, prompt: "inspect") - let accept = AgentRunRequest(provider: .codex, approval: .accept, prompt: "edit") - let auto = AgentRunRequest(provider: .codex, approval: .auto, prompt: "ship") + func testCodexCommandUsesApprovalSandbox() 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'") } - func testClaudeCommandUsesMatchingPermissionMode() { - let request = AgentRunRequest( + func testClaudeCommandUsesMatchingPermissionMode() throws { + let request = try XCTUnwrap(AgentRunRequest( provider: .claude, approval: .accept, prompt: "make the change" - ) + )) XCTAssertEqual( request.shellCommand, @@ -25,13 +26,32 @@ final class AgentRunRequestTests: XCTestCase { ) } - func testCommandQuotesSingleQuotesInPrompt() { - let request = AgentRunRequest( - provider: .openCode, + func testCommandQuotesSingleQuotesInPrompt() throws { + let request = try XCTUnwrap(AgentRunRequest( + provider: .codex, approval: .plan, prompt: "fix user's command" + )) + + XCTAssertEqual(request.shellCommand, "codex exec --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")) + } + + func testDoesNotRecordPromptUntilLocalTerminalStarts() { + let workspace = AgentWorkspaceModel() + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" ) + workspace.prompt = "implement this" + + workspace.submit(to: terminal) - XCTAssertEqual(request.shellCommand, "opencode run 'fix user'\"'\"'s command'") + XCTAssertTrue(workspace.submittedPrompts.isEmpty) + XCTAssertEqual(workspace.submissionError, "The local terminal is still opening.") } } diff --git a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift index 1c67d5a..020d492 100644 --- a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift +++ b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift @@ -168,6 +168,21 @@ final class AppShellPolicyTests: XCTestCase { XCTAssertEqual(coordinator.panel.level, .normal) } + func testAgentCoordinatorDoesNotInstallHiddenWorkspaceShortcuts() { + let coordinator = MikuPanelCoordinator( + screenFrame: CGRect(x: 0, y: 0, width: 1_280, height: 800), + terminalSession: TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ), + ordersFront: false + ) + + XCTAssertNil(coordinator.panel.onNewTab) + XCTAssertNil(coordinator.panel.onSplitRight) + XCTAssertNil(coordinator.panel.onSplitBottom) + } + func testCommandWRoutesToCloseWithoutClosingPanel() throws { let panel = MikuPanel( contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), From 6342931389f6fb7e75319a0d81057727f619074d Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 17:58:25 +0900 Subject: [PATCH 03/10] fix: confirm agent prompt delivery Signed-off-by: sionic-khope --- .../Agent/AgentWorkspaceModel.swift | 7 ++-- Sources/MikuCodeApp/AgentWorkspaceView.swift | 33 +++++++++++-------- .../Session/TerminalSessionModel.swift | 18 ++++++++-- .../TerminalSessionModelTests.swift | 10 ++++++ 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift index 46e78d1..9140e4c 100644 --- a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -89,8 +89,7 @@ struct LocalPTYAgentRunner { func start(_ request: AgentRunRequest) -> Bool { guard terminalSession.isRunning else { return false } - terminalSession.submit(request.shellCommand) - return true + return terminalSession.submit(request.shellCommand) } } @@ -112,7 +111,9 @@ final class AgentWorkspaceModel: ObservableObject { return } guard LocalPTYAgentRunner(terminalSession: terminalSession).start(request) else { - submissionError = "The local terminal is still opening." + submissionError = terminalSession.isRunning + ? "The local terminal could not accept this prompt." + : "The local terminal is still opening." return } submittedPrompts.append(AgentPrompt(provider: provider, approval: approval, text: text)) diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index 4eaacf3..29ce8e0 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -78,11 +78,18 @@ struct AgentWorkspaceView: View { VStack(alignment: .leading, spacing: 16) { if workspace.submittedPrompts.isEmpty { VStack(alignment: .leading, spacing: 8) { - Text("What are we building?") - .font(.system(size: 21, weight: .semibold, design: .rounded)) - .foregroundStyle(.white) - Text("Choose an agent, set its permission mode, then describe the task.") - .font(.system(size: 13, design: .rounded)) + Text(""" + M M III K K U U / CCCC OOO DDD EEEE + MM MM I K K U U / C O O D D E + M M M I KK U U / C O O D D EEE + M M I K K U U / C O O D D E + M M III K K UU / CCCC OOO DDD EEEE + """) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(AgentWorkspacePalette.teal) + .fixedSize(horizontal: true, vertical: false) + Text("local coding agent desktop") + .font(.system(size: 12, design: .rounded)) .foregroundStyle(AgentWorkspacePalette.muted) } .frame(maxWidth: .infinity, alignment: .leading) @@ -229,12 +236,12 @@ private struct AgentWorkspacePill: View { } private enum AgentWorkspacePalette { - static let surface = Color(red: 0.045, green: 0.060, blue: 0.090) - static let header = Color(red: 0.060, green: 0.078, blue: 0.112) - static let composer = Color(red: 0.075, green: 0.098, blue: 0.136) - static let message = Color.white.opacity(0.065) - static let control = Color.white.opacity(0.075) - static let rim = Color.white.opacity(0.12) - static let muted = Color.white.opacity(0.56) - static let teal = Color(red: 0.35, green: 0.88, blue: 0.86) + static let surface = Color(red: 0.026, green: 0.073, blue: 0.122) + static let header = Color(red: 0.040, green: 0.105, blue: 0.166) + static let composer = Color(red: 0.055, green: 0.130, blue: 0.196) + static let message = Color(red: 0.100, green: 0.255, blue: 0.350).opacity(0.18) + static let control = Color(red: 0.310, green: 0.700, blue: 0.890).opacity(0.16) + static let rim = Color(red: 0.340, green: 0.760, blue: 0.960).opacity(0.28) + static let muted = Color(red: 0.710, green: 0.840, blue: 0.940).opacity(0.70) + static let teal = Color(red: 0.390, green: 0.820, blue: 0.990) } diff --git a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift index 310ac25..7e19989 100644 --- a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift +++ b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift @@ -98,8 +98,9 @@ final class TerminalSessionModel: ObservableObject { } } - func submit(_ line: String) { - sendRawInput(Data((line + "\n").utf8)) + @discardableResult + func submit(_ line: String) -> Bool { + writeInput(Data((line + "\n").utf8)) } func interrupt() { @@ -107,13 +108,24 @@ final class TerminalSessionModel: ObservableObject { } func sendRawInput(_ data: Data) { + _ = writeInput(data) + } + + private func writeInput(_ data: Data) -> Bool { do { try lifecycle.validateInput(data) - try process?.write(data) + guard let process else { + statusText = "INPUT ERROR" + appendSystemMessage("Terminal process is unavailable.") + return false + } + try process.write(data) updateWorkingDirectory(from: data) + return true } catch { statusText = "INPUT ERROR" appendSystemMessage(error.localizedDescription) + return false } } diff --git a/Tests/MikuCodeAppTests/TerminalSessionModelTests.swift b/Tests/MikuCodeAppTests/TerminalSessionModelTests.swift index f908d02..1a951f2 100644 --- a/Tests/MikuCodeAppTests/TerminalSessionModelTests.swift +++ b/Tests/MikuCodeAppTests/TerminalSessionModelTests.swift @@ -44,6 +44,16 @@ final class TerminalSessionModelTests: XCTestCase { XCTAssertNotEqual(session.statusText, "INPUT ERROR") } + func testSubmitReportsFailureWhenSessionIsNotRunning() { + let session = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + + XCTAssertFalse(session.submit("echo unavailable")) + XCTAssertEqual(session.statusText, "INPUT ERROR") + } + func testNewSessionStartsInDesktopDirectory() async throws { let desktopPath = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Desktop", isDirectory: true) From 4a9691db266784db61867206f9179d37234f1137 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 18:07:22 +0900 Subject: [PATCH 04/10] fix: report rejected PTY input --- .../MikuCodeApp/Terminal/PTY/PTYProcess.swift | 25 +++++++++++++------ .../Session/TerminalSessionModel.swift | 12 +++++++++ Tests/MikuCodeAppTests/PTYProcessTests.swift | 17 +++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift index a5619b0..71301e7 100644 --- a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift +++ b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift @@ -50,7 +50,8 @@ final class PTYProcess: @unchecked Sendable { outputCapacity: @escaping OutputCapacityHandler = { .max }, onOutput: @escaping OutputHandler, onExit: @escaping ExitHandler, - onInputError: @escaping InputErrorHandler = { _ in } + onInputError: @escaping InputErrorHandler = { _ in }, + writeOperation: WriteOperation? = nil ) throws -> PTYProcess { guard let shellPath = strdup(shell), let loginArgument = strdup("-l") @@ -122,7 +123,7 @@ final class PTYProcess: @unchecked Sendable { outputCapacity: outputCapacity, onExit: onExit, onInputError: onInputError, - writeOperation: Self.systemWrite + writeOperation: writeOperation ?? Self.systemWrite ) } @@ -161,7 +162,7 @@ final class PTYProcess: @unchecked Sendable { Darwin.close(masterFileDescriptor) } writeSource.setEventHandler { [weak self] in - self?.drainPendingInput() + self?.drainPendingInputFromWriteSource() } readSource.resume() monitorExit() @@ -177,7 +178,7 @@ final class PTYProcess: @unchecked Sendable { try syncOnQueue { guard isProcessActive else { throw TerminalSessionError.notRunning } try pendingInput.enqueue(data) - drainPendingInput() + try drainPendingInput() } } @@ -315,7 +316,17 @@ final class PTYProcess: @unchecked Sendable { } } - private func drainPendingInput() { + private func drainPendingInputFromWriteSource() { + do { + try drainPendingInput() + } catch let error as TerminalSessionError { + onInputError(error) + } catch { + onInputError(.systemCall("write", EIO)) + } + } + + private func drainPendingInput() throws { guard isProcessActive else { pendingInput.removeAll() return @@ -334,11 +345,11 @@ final class PTYProcess: @unchecked Sendable { } catch let error as TerminalSessionError { pendingInput.removeAll() suspendWriteSource() - onInputError(error) + throw error } catch { pendingInput.removeAll() suspendWriteSource() - onInputError(.systemCall("write", EIO)) + throw TerminalSessionError.systemCall("write", EIO) } } diff --git a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift index 7e19989..0b57bb2 100644 --- a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift +++ b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift @@ -85,6 +85,12 @@ final class TerminalSessionModel: ObservableObject { self?.flushPendingOutput() self?.didExit(status: status) } + }, + onInputError: { [weak self] error in + Task { @MainActor [weak self] in + guard self?.generation == currentGeneration else { return } + self?.didReceiveInputError(error) + } } ) try lifecycle.start() @@ -208,6 +214,12 @@ final class TerminalSessionModel: ObservableObject { saveSession() } + private func didReceiveInputError(_ error: TerminalSessionError) { + guard isRunning else { return } + statusText = "INPUT ERROR" + appendSystemMessage(error.localizedDescription) + } + private func appendSystemMessage(_ message: String) { if buffer.snapshot.lines.last?.cells.isEmpty == false { buffer.ingest("\n") diff --git a/Tests/MikuCodeAppTests/PTYProcessTests.swift b/Tests/MikuCodeAppTests/PTYProcessTests.swift index 5ea23c8..55c7f4f 100644 --- a/Tests/MikuCodeAppTests/PTYProcessTests.swift +++ b/Tests/MikuCodeAppTests/PTYProcessTests.swift @@ -63,6 +63,23 @@ final class PTYProcessTests: XCTestCase { XCTAssertEqual(buffer.byteCount, 4) } + func testImmediateWriteFailureIsReportedToCaller() throws { + let reportedError = LockedValue() + let process = try PTYProcess.launch( + shell: "/bin/sh", + onOutput: { _ in }, + onExit: { _ in }, + onInputError: { error in reportedError.set(error) }, + writeOperation: { _, _ in .failed(EIO) } + ) + defer { process.stop() } + + XCTAssertThrowsError(try process.write(Data("echo lost\\n".utf8))) { error in + XCTAssertEqual(error as? TerminalSessionError, .systemCall("write", EIO)) + } + XCTAssertNil(reportedError.value) + } + func testLargeFramedPasteArrivesInOrderThroughPTYBackpressure() throws { let payload = Data(repeating: 0x41, count: 64 * 1024) var framedPaste = Data([0x1B, 0x5B, 0x32, 0x30, 0x30, 0x7E]) From 8fe5c7d9c0b506646d6143d3ea19601dc992a16f Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 18:19:35 +0900 Subject: [PATCH 05/10] fix: preserve deferred agent prompt delivery --- .../Agent/AgentWorkspaceModel.swift | 47 ++++++++++++-- Sources/MikuCodeApp/AgentWorkspaceView.swift | 30 ++++++++- .../Companion/MikuPanelCoordinator.swift | 9 ++- .../MikuCodeApp/Terminal/PTY/PTYProcess.swift | 44 ++++++++++--- .../Session/TerminalSessionModel.swift | 33 ++++++++-- .../AgentRunRequestTests.swift | 38 +++++++++++ .../AppShellPolicyTests.swift | 24 +++++++ Tests/MikuCodeAppTests/PTYProcessTests.swift | 64 +++++++++++++++++++ 8 files changed, 265 insertions(+), 24 deletions(-) diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift index 9140e4c..afb56ae 100644 --- a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -87,20 +87,30 @@ struct AgentRunRequest: Equatable { struct LocalPTYAgentRunner { let terminalSession: TerminalSessionModel - func start(_ request: AgentRunRequest) -> Bool { - guard terminalSession.isRunning else { return false } - return terminalSession.submit(request.shellCommand) + func start(_ request: AgentRunRequest) -> TerminalInputDelivery? { + guard terminalSession.isRunning else { return nil } + return terminalSession.submitAgentCommand(request.shellCommand) } } @MainActor final class AgentWorkspaceModel: ObservableObject { + typealias RequestStarter = (TerminalSessionModel, AgentRunRequest) -> TerminalInputDelivery? + @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? @Published var isTerminalPresented = false @Published private(set) var submissionError: String? + private let startRequest: RequestStarter + + init(startRequest: @escaping RequestStarter = { terminalSession, request in + LocalPTYAgentRunner(terminalSession: terminalSession).start(request) + }) { + self.startRequest = startRequest + } func submit(to terminalSession: TerminalSessionModel) { let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) @@ -110,17 +120,44 @@ final class AgentWorkspaceModel: ObservableObject { : "This provider is not available in the current workspace." return } - guard LocalPTYAgentRunner(terminalSession: terminalSession).start(request) else { + terminalSession.onInputDeliveryUpdate = { [weak self] update in + self?.receiveInputDelivery(update) + } + let agentPrompt = AgentPrompt(provider: provider, approval: approval, text: text) + guard let delivery = startRequest(terminalSession, request) else { submissionError = terminalSession.isRunning ? "The local terminal could not accept this prompt." : "The local terminal is still opening." return } - submittedPrompts.append(AgentPrompt(provider: provider, approval: approval, text: text)) + switch delivery { + case .delivered: + submittedPrompts.append(agentPrompt) + case .queued: + pendingPrompt = agentPrompt + } prompt = "" submissionError = nil isTerminalPresented = true } + + var isAwaitingPromptDelivery: Bool { + pendingPrompt != nil + } + + private func receiveInputDelivery(_ update: TerminalInputDeliveryUpdate) { + guard let pendingPrompt else { return } + switch update { + case .delivered: + submittedPrompts.append(pendingPrompt) + self.pendingPrompt = nil + submissionError = nil + case .failed: + self.pendingPrompt = nil + prompt = prompt.isEmpty ? pendingPrompt.text : prompt + submissionError = "The queued prompt was not delivered." + } + } } struct AgentPrompt: Identifiable { diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index 29ce8e0..227a313 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -76,7 +76,7 @@ struct AgentWorkspaceView: View { private var conversation: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { - if workspace.submittedPrompts.isEmpty { + if workspace.submittedPrompts.isEmpty, workspace.pendingPrompt == nil { VStack(alignment: .leading, spacing: 8) { Text(""" M M III K K U U / CCCC OOO DDD EEEE @@ -109,11 +109,16 @@ struct AgentWorkspaceView: View { .background(AgentWorkspacePalette.message) .clipShape(RoundedRectangle(cornerRadius: 12)) } - HStack(spacing: 8) { + if let prompt = workspace.pendingPrompt { + AgentPromptCard(prompt: prompt, status: "waiting for terminal capacity") + } + if !workspace.submittedPrompts.isEmpty { + HStack(spacing: 8) { Circle().fill(AgentWorkspacePalette.teal).frame(width: 6, height: 6) Text("Running in the integrated terminal") .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundStyle(AgentWorkspacePalette.muted) + } } } if let error = workspace.submissionError { @@ -207,6 +212,7 @@ struct AgentWorkspaceView: View { private var canSubmit: Bool { terminalSession.isRunning && workspace.provider.supportsApproval + && !workspace.isAwaitingPromptDelivery && !workspace.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -217,6 +223,26 @@ struct AgentWorkspaceView: View { } } +private struct AgentPromptCard: View { + let prompt: AgentPrompt + let status: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("you · \(prompt.provider.title) · \(status)") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(AgentWorkspacePalette.teal) + Text(prompt.text) + .font(.system(size: 14, design: .rounded)) + .foregroundStyle(.white) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentWorkspacePalette.message) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + private struct AgentWorkspacePill: View { let title: String diff --git a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift index 8f7d812..121ec3f 100644 --- a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift +++ b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift @@ -243,11 +243,14 @@ final class MikuPanel: NSPanel { if isTerminalPresented, let action = TerminalWorkspaceShortcut.action(for: event) { switch action { case .newTab: - onNewTab?() + guard let onNewTab else { return super.performKeyEquivalent(with: event) } + onNewTab() case .splitRight: - onSplitRight?() + guard let onSplitRight else { return super.performKeyEquivalent(with: event) } + onSplitRight() case .splitBottom: - onSplitBottom?() + guard let onSplitBottom else { return super.performKeyEquivalent(with: event) } + onSplitBottom() } return true } diff --git a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift index 71301e7..0679e73 100644 --- a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift +++ b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift @@ -7,6 +7,8 @@ final class PTYProcess: @unchecked Sendable { typealias OutputHandler = @Sendable (Data) -> Void typealias ExitHandler = @Sendable (Int32) -> Void typealias InputErrorHandler = @Sendable (TerminalSessionError) -> Void + typealias InputDeliveredHandler = @Sendable () -> Void + typealias BeforeInputDrainHandler = @Sendable () -> Void typealias WriteOperation = @Sendable (Int32, UnsafeRawBufferPointer) -> PTYWriteResult static let outboundCapacity = 1_048_576 @@ -34,6 +36,8 @@ final class PTYProcess: @unchecked Sendable { private let outputCapacity: OutputCapacityHandler private let onExit: ExitHandler private let onInputError: InputErrorHandler + private let onInputDelivered: InputDeliveredHandler + private let beforeInputDrain: BeforeInputDrainHandler private let inputChannel: PTYInputChannel private let state = PTYProcessState() private var readBuffer = [UInt8](repeating: 0, count: 8_192) @@ -51,6 +55,8 @@ final class PTYProcess: @unchecked Sendable { onOutput: @escaping OutputHandler, onExit: @escaping ExitHandler, onInputError: @escaping InputErrorHandler = { _ in }, + onInputDelivered: @escaping InputDeliveredHandler = {}, + beforeInputDrain: @escaping BeforeInputDrainHandler = {}, writeOperation: WriteOperation? = nil ) throws -> PTYProcess { guard let shellPath = strdup(shell), @@ -123,6 +129,8 @@ final class PTYProcess: @unchecked Sendable { outputCapacity: outputCapacity, onExit: onExit, onInputError: onInputError, + onInputDelivered: onInputDelivered, + beforeInputDrain: beforeInputDrain, writeOperation: writeOperation ?? Self.systemWrite ) } @@ -134,6 +142,8 @@ final class PTYProcess: @unchecked Sendable { outputCapacity: @escaping OutputCapacityHandler, onExit: @escaping ExitHandler, onInputError: @escaping InputErrorHandler, + onInputDelivered: @escaping InputDeliveredHandler, + beforeInputDrain: @escaping BeforeInputDrainHandler, writeOperation: @escaping WriteOperation ) { self.processID = processID @@ -142,6 +152,8 @@ final class PTYProcess: @unchecked Sendable { self.outputCapacity = outputCapacity self.onExit = onExit self.onInputError = onInputError + self.onInputDelivered = onInputDelivered + self.beforeInputDrain = beforeInputDrain inputChannel = PTYInputChannel( ownedFileDescriptor: masterFileDescriptor, writeOperation: writeOperation @@ -172,13 +184,15 @@ final class PTYProcess: @unchecked Sendable { stopAndDrain() } - func write(_ data: Data) throws { + @discardableResult + func write(_ data: Data) throws -> TerminalInputDelivery { guard !data.isEmpty else { throw TerminalSessionError.emptyInput } - try syncOnQueue { + return try syncOnQueue { guard isProcessActive else { throw TerminalSessionError.notRunning } try pendingInput.enqueue(data) - try drainPendingInput() + beforeInputDrain() + return try drainPendingInput(throwWhenInactive: true) } } @@ -308,17 +322,19 @@ final class PTYProcess: @unchecked Sendable { } } - private func syncOnQueue(_ work: () throws -> Void) rethrows { + private func syncOnQueue(_ work: () throws -> T) rethrows -> T { if DispatchQueue.getSpecific(key: queueKey) != nil { - try work() + return try work() } else { - try queue.sync(execute: work) + return try queue.sync(execute: work) } } private func drainPendingInputFromWriteSource() { do { - try drainPendingInput() + if try drainPendingInput(throwWhenInactive: false) == .delivered { + onInputDelivered() + } } catch let error as TerminalSessionError { onInputError(error) } catch { @@ -326,10 +342,13 @@ final class PTYProcess: @unchecked Sendable { } } - private func drainPendingInput() throws { + private func drainPendingInput( + throwWhenInactive: Bool + ) throws -> TerminalInputDelivery { guard isProcessActive else { pendingInput.removeAll() - return + if throwWhenInactive { throw TerminalSessionError.notRunning } + return .queued } do { let result = try pendingInput.drain { [inputChannel] data in @@ -339,8 +358,10 @@ final class PTYProcess: @unchecked Sendable { } if result == .wouldBlock { resumeWriteSource() + return .queued } else { suspendWriteSource() + return .delivered } } catch let error as TerminalSessionError { pendingInput.removeAll() @@ -428,6 +449,11 @@ enum PTYWriteResult: Equatable, Sendable { case failed(Int32) } +enum TerminalInputDelivery: Equatable, Sendable { + case delivered + case queued +} + struct PTYOutboundBuffer: Sendable { private struct Chunk: Sendable { var data: Data diff --git a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift index 0b57bb2..480a3fc 100644 --- a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift +++ b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift @@ -8,6 +8,7 @@ final class TerminalSessionModel: ObservableObject { @Published private(set) var statusText = "STOPPED" @Published private(set) var isRunning = false @Published private(set) var isBracketedPasteEnabled = false + var onInputDeliveryUpdate: ((TerminalInputDeliveryUpdate) -> Void)? private var buffer = TerminalBuffer(columns: 80, rows: 24) private var lifecycle = TerminalSessionLifecycle() @@ -91,6 +92,12 @@ final class TerminalSessionModel: ObservableObject { guard self?.generation == currentGeneration else { return } self?.didReceiveInputError(error) } + }, + onInputDelivered: { [weak self] in + Task { @MainActor [weak self] in + guard self?.generation == currentGeneration else { return } + self?.didDeliverQueuedInput() + } } ) try lifecycle.start() @@ -106,6 +113,10 @@ final class TerminalSessionModel: ObservableObject { @discardableResult func submit(_ line: String) -> Bool { + writeInput(Data((line + "\n").utf8)) != nil + } + + func submitAgentCommand(_ line: String) -> TerminalInputDelivery? { writeInput(Data((line + "\n").utf8)) } @@ -117,21 +128,21 @@ final class TerminalSessionModel: ObservableObject { _ = writeInput(data) } - private func writeInput(_ data: Data) -> Bool { + private func writeInput(_ data: Data) -> TerminalInputDelivery? { do { try lifecycle.validateInput(data) guard let process else { statusText = "INPUT ERROR" appendSystemMessage("Terminal process is unavailable.") - return false + return nil } - try process.write(data) + let delivery = try process.write(data) updateWorkingDirectory(from: data) - return true + return delivery } catch { statusText = "INPUT ERROR" appendSystemMessage(error.localizedDescription) - return false + return nil } } @@ -186,6 +197,7 @@ final class TerminalSessionModel: ObservableObject { isRunning = false isBracketedPasteEnabled = false statusText = "STOPPED" + onInputDeliveryUpdate?(.failed(.notRunning)) } private func flushPendingOutput() { @@ -212,12 +224,18 @@ final class TerminalSessionModel: ObservableObject { statusText = status == 0 ? "EXITED" : "EXIT \(status)" appendSystemMessage("Shell exited with status \(status).") saveSession() + onInputDeliveryUpdate?(.failed(.notRunning)) } private func didReceiveInputError(_ error: TerminalSessionError) { guard isRunning else { return } statusText = "INPUT ERROR" appendSystemMessage(error.localizedDescription) + onInputDeliveryUpdate?(.failed(error)) + } + + private func didDeliverQueuedInput() { + onInputDeliveryUpdate?(.delivered) } private func appendSystemMessage(_ message: String) { @@ -309,6 +327,11 @@ final class TerminalSessionModel: ObservableObject { private static let maximumTrackedInputBytes = 4 * 1024 } +enum TerminalInputDeliveryUpdate: Equatable { + case delivered + case failed(TerminalSessionError) +} + struct PendingTerminalOutputBatch: Sendable { let chunks: [Data] let hasDiscontinuity: Bool diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift index 97b4c77..5cedd6d 100644 --- a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -54,4 +54,42 @@ final class AgentRunRequestTests: XCTestCase { XCTAssertTrue(workspace.submittedPrompts.isEmpty) XCTAssertEqual(workspace.submissionError, "The local terminal is still opening.") } + + func testQueuedPromptIsRecordedOnlyAfterTerminalDelivery() { + let workspace = AgentWorkspaceModel(startRequest: { _, _ in .queued }) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "wait for capacity" + + workspace.submit(to: terminal) + + XCTAssertTrue(workspace.submittedPrompts.isEmpty) + XCTAssertEqual(workspace.pendingPrompt?.text, "wait for capacity") + XCTAssertTrue(workspace.isAwaitingPromptDelivery) + + terminal.onInputDeliveryUpdate?(.delivered) + + XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["wait for capacity"]) + XCTAssertNil(workspace.pendingPrompt) + XCTAssertFalse(workspace.isAwaitingPromptDelivery) + } + + func testQueuedPromptIsRestoredWhenTerminalRejectsItLater() { + let workspace = AgentWorkspaceModel(startRequest: { _, _ in .queued }) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "retry after failure" + + workspace.submit(to: terminal) + terminal.onInputDeliveryUpdate?(.failed(.systemCall("write", EIO))) + + XCTAssertTrue(workspace.submittedPrompts.isEmpty) + XCTAssertNil(workspace.pendingPrompt) + XCTAssertEqual(workspace.prompt, "retry after failure") + XCTAssertEqual(workspace.submissionError, "The queued prompt was not delivered.") + } } diff --git a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift index 020d492..89dfc18 100644 --- a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift +++ b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift @@ -183,6 +183,30 @@ final class AppShellPolicyTests: XCTestCase { XCTAssertNil(coordinator.panel.onSplitBottom) } + func testAgentPanelDoesNotConsumeWorkspaceShortcutsWithoutHandlers() throws { + let panel = MikuPanel( + contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + panel.isTerminalPresented = true + let commandT = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: .command, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "t", + charactersIgnoringModifiers: "t", + isARepeat: false, + keyCode: 17 + )) + + XCTAssertFalse(panel.performKeyEquivalent(with: commandT)) + } + func testCommandWRoutesToCloseWithoutClosingPanel() throws { let panel = MikuPanel( contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), diff --git a/Tests/MikuCodeAppTests/PTYProcessTests.swift b/Tests/MikuCodeAppTests/PTYProcessTests.swift index 55c7f4f..5383363 100644 --- a/Tests/MikuCodeAppTests/PTYProcessTests.swift +++ b/Tests/MikuCodeAppTests/PTYProcessTests.swift @@ -80,6 +80,44 @@ final class PTYProcessTests: XCTestCase { XCTAssertNil(reportedError.value) } + func testStoppedProcessBetweenQueueAndDrainRejectsInput() throws { + let stopper = PTYProcessStopper() + let process = try PTYProcess.launch( + shell: "/bin/sh", + onOutput: { _ in }, + onExit: { _ in }, + beforeInputDrain: { stopper.stop() } + ) + stopper.install(process) + + XCTAssertThrowsError(try process.write(Data("echo stale\\n".utf8))) { error in + XCTAssertEqual(error as? TerminalSessionError, .notRunning) + } + } + + func testDeferredWriteFailureReportsInputErrorAfterQueueing() throws { + let inputFailure = expectation(description: "deferred PTY input failure") + let observedError = LockedValue() + let writeAttempts = PTYWriteAttemptCounter() + let process = try PTYProcess.launch( + shell: "/bin/sh", + onOutput: { _ in }, + onExit: { _ in }, + onInputError: { error in + observedError.set(error) + inputFailure.fulfill() + }, + writeOperation: { _, _ in + writeAttempts.next() == 1 ? .wouldBlock : .failed(EIO) + } + ) + defer { process.stop() } + + XCTAssertEqual(try process.write(Data("echo queued\\n".utf8)), .queued) + wait(for: [inputFailure], timeout: 5) + XCTAssertEqual(observedError.value, .systemCall("write", EIO)) + } + func testLargeFramedPasteArrivesInOrderThroughPTYBackpressure() throws { let payload = Data(repeating: 0x41, count: 64 * 1024) var framedPaste = Data([0x1B, 0x5B, 0x32, 0x30, 0x30, 0x7E]) @@ -431,6 +469,32 @@ private final class LockedValue: @unchecked Sendable { } } +private final class PTYProcessStopper: @unchecked Sendable { + private let lock = NSLock() + private var process: PTYProcess? + + func install(_ process: PTYProcess) { + lock.withLock { self.process = process } + } + + func stop() { + let process = lock.withLock { self.process } + process?.stop() + } +} + +private final class PTYWriteAttemptCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func next() -> Int { + lock.withLock { + count += 1 + return count + } + } +} + private final class PTYPIDCapture: @unchecked Sendable { private static let marker = "__MIKU_CHILD_PID__" From e6abd1a6c4e62fbeef1b357e3470ce18c5d88241 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 18:28:00 +0900 Subject: [PATCH 06/10] fix: identify deferred prompt delivery --- .../Agent/AgentWorkspaceModel.swift | 26 ++++++--- .../MikuCodeApp/Terminal/PTY/PTYProcess.swift | 37 +++++++++---- .../Session/TerminalSessionModel.swift | 53 +++++++++++++------ .../AgentRunRequestTests.swift | 35 +++++++++--- Tests/MikuCodeAppTests/PTYProcessTests.swift | 4 +- 5 files changed, 114 insertions(+), 41 deletions(-) diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift index afb56ae..48019ce 100644 --- a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -87,7 +87,7 @@ struct AgentRunRequest: Equatable { struct LocalPTYAgentRunner { let terminalSession: TerminalSessionModel - func start(_ request: AgentRunRequest) -> TerminalInputDelivery? { + func start(_ request: AgentRunRequest) -> TerminalAgentInputSubmission? { guard terminalSession.isRunning else { return nil } return terminalSession.submitAgentCommand(request.shellCommand) } @@ -95,13 +95,14 @@ struct LocalPTYAgentRunner { @MainActor final class AgentWorkspaceModel: ObservableObject { - typealias RequestStarter = (TerminalSessionModel, AgentRunRequest) -> TerminalInputDelivery? + typealias RequestStarter = (TerminalSessionModel, AgentRunRequest) -> TerminalAgentInputSubmission? @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? @Published var isTerminalPresented = false @Published private(set) var submissionError: String? private let startRequest: RequestStarter @@ -124,17 +125,18 @@ final class AgentWorkspaceModel: ObservableObject { self?.receiveInputDelivery(update) } let agentPrompt = AgentPrompt(provider: provider, approval: approval, text: text) - guard let delivery = startRequest(terminalSession, request) else { + guard let submission = startRequest(terminalSession, request) else { submissionError = terminalSession.isRunning ? "The local terminal could not accept this prompt." : "The local terminal is still opening." return } - switch delivery { + switch submission.delivery { case .delivered: submittedPrompts.append(agentPrompt) case .queued: pendingPrompt = agentPrompt + pendingPromptDeliveryToken = submission.deliveryToken } prompt = "" submissionError = nil @@ -148,15 +150,25 @@ final class AgentWorkspaceModel: ObservableObject { private func receiveInputDelivery(_ update: TerminalInputDeliveryUpdate) { guard let pendingPrompt else { return } switch update { - case .delivered: + case let .delivered(deliveryToken): + guard deliveryToken == pendingPromptDeliveryToken else { return } submittedPrompts.append(pendingPrompt) self.pendingPrompt = nil + pendingPromptDeliveryToken = nil submissionError = nil - case .failed: + case let .failed(deliveryToken, _): + guard deliveryToken == pendingPromptDeliveryToken else { return } + failPendingPrompt(pendingPrompt) + case .sessionEnded: + failPendingPrompt(pendingPrompt) + } + } + + private func failPendingPrompt(_ pendingPrompt: AgentPrompt) { self.pendingPrompt = nil + pendingPromptDeliveryToken = nil prompt = prompt.isEmpty ? pendingPrompt.text : prompt submissionError = "The queued prompt was not delivered." - } } } diff --git a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift index 0679e73..2519e3b 100644 --- a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift +++ b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift @@ -6,8 +6,8 @@ final class PTYProcess: @unchecked Sendable { typealias OutputCapacityHandler = @Sendable () -> Int typealias OutputHandler = @Sendable (Data) -> Void typealias ExitHandler = @Sendable (Int32) -> Void - typealias InputErrorHandler = @Sendable (TerminalSessionError) -> Void - typealias InputDeliveredHandler = @Sendable () -> Void + typealias InputErrorHandler = @Sendable (UUID?, TerminalSessionError) -> Void + typealias InputDeliveredHandler = @Sendable (UUID) -> Void typealias BeforeInputDrainHandler = @Sendable () -> Void typealias WriteOperation = @Sendable (Int32, UnsafeRawBufferPointer) -> PTYWriteResult @@ -42,6 +42,7 @@ final class PTYProcess: @unchecked Sendable { private let state = PTYProcessState() private var readBuffer = [UInt8](repeating: 0, count: 8_192) private var pendingInput = PTYOutboundBuffer(capacity: PTYProcess.outboundCapacity) + private var pendingDeliveryToken: UUID? private var writeSourceResumed = false private var writeSourceCancelled = false private var readSourceResumed = true @@ -54,8 +55,8 @@ final class PTYProcess: @unchecked Sendable { outputCapacity: @escaping OutputCapacityHandler = { .max }, onOutput: @escaping OutputHandler, onExit: @escaping ExitHandler, - onInputError: @escaping InputErrorHandler = { _ in }, - onInputDelivered: @escaping InputDeliveredHandler = {}, + onInputError: @escaping InputErrorHandler = { _, _ in }, + onInputDelivered: @escaping InputDeliveredHandler = { _ in }, beforeInputDrain: @escaping BeforeInputDrainHandler = {}, writeOperation: WriteOperation? = nil ) throws -> PTYProcess { @@ -185,14 +186,21 @@ final class PTYProcess: @unchecked Sendable { } @discardableResult - func write(_ data: Data) throws -> TerminalInputDelivery { + func write( + _ data: Data, + deliveryToken: UUID? = nil + ) throws -> TerminalInputDelivery { guard !data.isEmpty else { throw TerminalSessionError.emptyInput } return try syncOnQueue { guard isProcessActive else { throw TerminalSessionError.notRunning } try pendingInput.enqueue(data) beforeInputDrain() - return try drainPendingInput(throwWhenInactive: true) + let delivery = try drainPendingInput(throwWhenInactive: true) + if delivery == .queued { + pendingDeliveryToken = deliveryToken + } + return delivery } } @@ -332,16 +340,24 @@ final class PTYProcess: @unchecked Sendable { private func drainPendingInputFromWriteSource() { do { - if try drainPendingInput(throwWhenInactive: false) == .delivered { - onInputDelivered() + if try drainPendingInput(throwWhenInactive: false) == .delivered, + let deliveryToken = pendingDeliveryToken { + pendingDeliveryToken = nil + onInputDelivered(deliveryToken) } } catch let error as TerminalSessionError { - onInputError(error) + reportInputError(error) } catch { - onInputError(.systemCall("write", EIO)) + reportInputError(.systemCall("write", EIO)) } } + private func reportInputError(_ error: TerminalSessionError) { + let deliveryToken = pendingDeliveryToken + pendingDeliveryToken = nil + onInputError(deliveryToken, error) + } + private func drainPendingInput( throwWhenInactive: Bool ) throws -> TerminalInputDelivery { @@ -388,6 +404,7 @@ final class PTYProcess: @unchecked Sendable { private func cancelSourcesAndPendingInput() { pendingInput.removeAll() + pendingDeliveryToken = nil guard !writeSourceCancelled else { return } if !writeSourceResumed { writeSource.resume() diff --git a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift index 480a3fc..1b05dc9 100644 --- a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift +++ b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift @@ -87,16 +87,16 @@ final class TerminalSessionModel: ObservableObject { self?.didExit(status: status) } }, - onInputError: { [weak self] error in + onInputError: { [weak self] deliveryToken, error in Task { @MainActor [weak self] in guard self?.generation == currentGeneration else { return } - self?.didReceiveInputError(error) + self?.didReceiveInputError(deliveryToken: deliveryToken, error: error) } }, - onInputDelivered: { [weak self] in + onInputDelivered: { [weak self] deliveryToken in Task { @MainActor [weak self] in guard self?.generation == currentGeneration else { return } - self?.didDeliverQueuedInput() + self?.didDeliverQueuedInput(deliveryToken: deliveryToken) } } ) @@ -116,8 +116,15 @@ final class TerminalSessionModel: ObservableObject { writeInput(Data((line + "\n").utf8)) != nil } - func submitAgentCommand(_ line: String) -> TerminalInputDelivery? { - writeInput(Data((line + "\n").utf8)) + func submitAgentCommand(_ line: String) -> TerminalAgentInputSubmission? { + let deliveryToken = UUID() + guard let delivery = writeInput( + Data((line + "\n").utf8), + deliveryToken: deliveryToken + ) else { + return nil + } + return TerminalAgentInputSubmission(delivery: delivery, deliveryToken: deliveryToken) } func interrupt() { @@ -128,7 +135,10 @@ final class TerminalSessionModel: ObservableObject { _ = writeInput(data) } - private func writeInput(_ data: Data) -> TerminalInputDelivery? { + private func writeInput( + _ data: Data, + deliveryToken: UUID? = nil + ) -> TerminalInputDelivery? { do { try lifecycle.validateInput(data) guard let process else { @@ -136,7 +146,7 @@ final class TerminalSessionModel: ObservableObject { appendSystemMessage("Terminal process is unavailable.") return nil } - let delivery = try process.write(data) + let delivery = try process.write(data, deliveryToken: deliveryToken) updateWorkingDirectory(from: data) return delivery } catch { @@ -197,7 +207,7 @@ final class TerminalSessionModel: ObservableObject { isRunning = false isBracketedPasteEnabled = false statusText = "STOPPED" - onInputDeliveryUpdate?(.failed(.notRunning)) + onInputDeliveryUpdate?(.sessionEnded) } private func flushPendingOutput() { @@ -224,18 +234,23 @@ final class TerminalSessionModel: ObservableObject { statusText = status == 0 ? "EXITED" : "EXIT \(status)" appendSystemMessage("Shell exited with status \(status).") saveSession() - onInputDeliveryUpdate?(.failed(.notRunning)) + onInputDeliveryUpdate?(.sessionEnded) } - private func didReceiveInputError(_ error: TerminalSessionError) { + private func didReceiveInputError( + deliveryToken: UUID?, + error: TerminalSessionError + ) { guard isRunning else { return } statusText = "INPUT ERROR" appendSystemMessage(error.localizedDescription) - onInputDeliveryUpdate?(.failed(error)) + if let deliveryToken { + onInputDeliveryUpdate?(.failed(deliveryToken, error)) + } } - private func didDeliverQueuedInput() { - onInputDeliveryUpdate?(.delivered) + private func didDeliverQueuedInput(deliveryToken: UUID) { + onInputDeliveryUpdate?(.delivered(deliveryToken)) } private func appendSystemMessage(_ message: String) { @@ -328,8 +343,14 @@ final class TerminalSessionModel: ObservableObject { } enum TerminalInputDeliveryUpdate: Equatable { - case delivered - case failed(TerminalSessionError) + case delivered(UUID) + case failed(UUID, TerminalSessionError) + case sessionEnded +} + +struct TerminalAgentInputSubmission: Equatable { + let delivery: TerminalInputDelivery + let deliveryToken: UUID } struct PendingTerminalOutputBatch: Sendable { diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift index 5cedd6d..6e1cf90 100644 --- a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -55,8 +55,10 @@ final class AgentRunRequestTests: XCTestCase { XCTAssertEqual(workspace.submissionError, "The local terminal is still opening.") } - func testQueuedPromptIsRecordedOnlyAfterTerminalDelivery() { - let workspace = AgentWorkspaceModel(startRequest: { _, _ in .queued }) + func testQueuedPromptIsRecordedOnlyAfterTerminalDelivery() throws { + let workspace = AgentWorkspaceModel(startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) + }) let terminal = TerminalSessionModel( persistence: TerminalSessionPersistence(url: nil), shell: "/bin/sh" @@ -69,15 +71,18 @@ final class AgentRunRequestTests: XCTestCase { XCTAssertEqual(workspace.pendingPrompt?.text, "wait for capacity") XCTAssertTrue(workspace.isAwaitingPromptDelivery) - terminal.onInputDeliveryUpdate?(.delivered) + let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) + terminal.onInputDeliveryUpdate?(.delivered(token)) XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["wait for capacity"]) XCTAssertNil(workspace.pendingPrompt) XCTAssertFalse(workspace.isAwaitingPromptDelivery) } - func testQueuedPromptIsRestoredWhenTerminalRejectsItLater() { - let workspace = AgentWorkspaceModel(startRequest: { _, _ in .queued }) + func testQueuedPromptIsRestoredWhenTerminalRejectsItLater() throws { + let workspace = AgentWorkspaceModel(startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) + }) let terminal = TerminalSessionModel( persistence: TerminalSessionPersistence(url: nil), shell: "/bin/sh" @@ -85,11 +90,29 @@ final class AgentRunRequestTests: XCTestCase { workspace.prompt = "retry after failure" workspace.submit(to: terminal) - terminal.onInputDeliveryUpdate?(.failed(.systemCall("write", EIO))) + let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) + terminal.onInputDeliveryUpdate?(.failed(token, .systemCall("write", EIO))) XCTAssertTrue(workspace.submittedPrompts.isEmpty) XCTAssertNil(workspace.pendingPrompt) XCTAssertEqual(workspace.prompt, "retry after failure") XCTAssertEqual(workspace.submissionError, "The queued prompt was not delivered.") } + + 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())) + + XCTAssertTrue(workspace.submittedPrompts.isEmpty) + XCTAssertNotNil(workspace.pendingPrompt) + } } diff --git a/Tests/MikuCodeAppTests/PTYProcessTests.swift b/Tests/MikuCodeAppTests/PTYProcessTests.swift index 5383363..1b6f734 100644 --- a/Tests/MikuCodeAppTests/PTYProcessTests.swift +++ b/Tests/MikuCodeAppTests/PTYProcessTests.swift @@ -69,7 +69,7 @@ final class PTYProcessTests: XCTestCase { shell: "/bin/sh", onOutput: { _ in }, onExit: { _ in }, - onInputError: { error in reportedError.set(error) }, + onInputError: { _, error in reportedError.set(error) }, writeOperation: { _, _ in .failed(EIO) } ) defer { process.stop() } @@ -103,7 +103,7 @@ final class PTYProcessTests: XCTestCase { shell: "/bin/sh", onOutput: { _ in }, onExit: { _ in }, - onInputError: { error in + onInputError: { _, error in observedError.set(error) inputFailure.fulfill() }, From 8348495b3c29ed2dbdd52e3fafe102b2cdf6d8ac Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 18:31:48 +0900 Subject: [PATCH 07/10] test: allow slower PTY backpressure runners --- Tests/MikuCodeAppTests/PTYProcessTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/MikuCodeAppTests/PTYProcessTests.swift b/Tests/MikuCodeAppTests/PTYProcessTests.swift index 1b6f734..c199cc4 100644 --- a/Tests/MikuCodeAppTests/PTYProcessTests.swift +++ b/Tests/MikuCodeAppTests/PTYProcessTests.swift @@ -143,7 +143,7 @@ final class PTYProcessTests: XCTestCase { try process.write(Data(command.utf8)) wait(for: [ready], timeout: 5) try process.write(framedPaste) - wait(for: [received, exited], timeout: 15) + wait(for: [received, exited], timeout: 30) XCTAssertEqual(capture.matchCount, 1) } From d8a4d455d68386c09e8d4c41195f10a047b6ad32 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 18:33:23 +0900 Subject: [PATCH 08/10] fix: retain queued agent delivery identity --- Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift index 2519e3b..88d28ec 100644 --- a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift +++ b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift @@ -198,7 +198,12 @@ final class PTYProcess: @unchecked Sendable { beforeInputDrain() let delivery = try drainPendingInput(throwWhenInactive: true) if delivery == .queued { - pendingDeliveryToken = deliveryToken + if let deliveryToken { + pendingDeliveryToken = deliveryToken + } + } else if let pendingDeliveryToken { + self.pendingDeliveryToken = nil + onInputDelivered(pendingDeliveryToken) } return delivery } From 8e860f0248f8b722cfde58ff53297049d395df81 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 18:58:13 +0900 Subject: [PATCH 09/10] fix: recover queued agent prompts --- .../Agent/AgentWorkspaceModel.swift | 14 +++++--- Sources/MikuCodeApp/AgentWorkspaceView.swift | 1 + .../MikuCodeApp/Terminal/PTY/PTYProcess.swift | 10 +++++- .../AgentRunRequestTests.swift | 8 +++++ Tests/MikuCodeAppTests/PTYProcessTests.swift | 33 +++++++++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift index 48019ce..4139f26 100644 --- a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -147,6 +147,10 @@ final class AgentWorkspaceModel: ObservableObject { pendingPrompt != nil } + var isComposerInteractionEnabled: Bool { + pendingPrompt == nil + } + private func receiveInputDelivery(_ update: TerminalInputDeliveryUpdate) { guard let pendingPrompt else { return } switch update { @@ -165,10 +169,12 @@ final class AgentWorkspaceModel: ObservableObject { } private func failPendingPrompt(_ pendingPrompt: AgentPrompt) { - self.pendingPrompt = nil - pendingPromptDeliveryToken = nil - prompt = prompt.isEmpty ? pendingPrompt.text : prompt - submissionError = "The queued prompt was not delivered." + self.pendingPrompt = nil + pendingPromptDeliveryToken = nil + provider = pendingPrompt.provider + approval = pendingPrompt.approval + prompt = pendingPrompt.text + submissionError = "The queued prompt was not delivered." } } diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index 227a313..2308315 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -188,6 +188,7 @@ struct AgentWorkspaceView: View { .padding(20) .background(AgentWorkspacePalette.header) .overlay(alignment: .top) { Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) } + .disabled(!workspace.isComposerInteractionEnabled) } private var embeddedTerminal: some View { diff --git a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift index 88d28ec..1a88532 100644 --- a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift +++ b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift @@ -196,7 +196,15 @@ final class PTYProcess: @unchecked Sendable { guard isProcessActive else { throw TerminalSessionError.notRunning } try pendingInput.enqueue(data) beforeInputDrain() - let delivery = try drainPendingInput(throwWhenInactive: true) + let delivery: TerminalInputDelivery + do { + delivery = try drainPendingInput(throwWhenInactive: true) + } catch let error as TerminalSessionError { + if pendingDeliveryToken != nil { + reportInputError(error) + } + throw error + } if delivery == .queued { if let deliveryToken { pendingDeliveryToken = deliveryToken diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift index 6e1cf90..123def9 100644 --- a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -70,6 +70,7 @@ final class AgentRunRequestTests: XCTestCase { XCTAssertTrue(workspace.submittedPrompts.isEmpty) XCTAssertEqual(workspace.pendingPrompt?.text, "wait for capacity") XCTAssertTrue(workspace.isAwaitingPromptDelivery) + XCTAssertFalse(workspace.isComposerInteractionEnabled) let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) terminal.onInputDeliveryUpdate?(.delivered(token)) @@ -77,6 +78,7 @@ final class AgentRunRequestTests: XCTestCase { XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["wait for capacity"]) XCTAssertNil(workspace.pendingPrompt) XCTAssertFalse(workspace.isAwaitingPromptDelivery) + XCTAssertTrue(workspace.isComposerInteractionEnabled) } func testQueuedPromptIsRestoredWhenTerminalRejectsItLater() throws { @@ -91,11 +93,17 @@ final class AgentRunRequestTests: XCTestCase { 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.") } diff --git a/Tests/MikuCodeAppTests/PTYProcessTests.swift b/Tests/MikuCodeAppTests/PTYProcessTests.swift index c199cc4..256ae29 100644 --- a/Tests/MikuCodeAppTests/PTYProcessTests.swift +++ b/Tests/MikuCodeAppTests/PTYProcessTests.swift @@ -118,6 +118,39 @@ final class PTYProcessTests: XCTestCase { XCTAssertEqual(observedError.value, .systemCall("write", EIO)) } + func testSynchronousFailureAfterQueuedAgentInputReportsQueuedToken() throws { + let inputFailure = expectation(description: "queued agent token receives synchronous failure") + let observedToken = LockedValue() + let writeAttempts = PTYWriteAttemptCounter() + let process = try PTYProcess.launch( + shell: "/bin/sh", + onOutput: { _ in }, + onExit: { _ in }, + onInputError: { deliveryToken, _ in + if let deliveryToken { + observedToken.set(deliveryToken) + inputFailure.fulfill() + } + }, + writeOperation: { _, _ in + writeAttempts.next() == 1 ? .wouldBlock : .failed(EIO) + } + ) + defer { process.stop() } + let agentToken = UUID() + + XCTAssertEqual( + try process.write(Data("echo queued\\n".utf8), deliveryToken: agentToken), + .queued + ) + XCTAssertThrowsError(try process.write(Data("echo raw\\n".utf8))) { error in + XCTAssertEqual(error as? TerminalSessionError, .systemCall("write", EIO)) + } + wait(for: [inputFailure], timeout: 5) + + XCTAssertEqual(observedToken.value, agentToken) + } + func testLargeFramedPasteArrivesInOrderThroughPTYBackpressure() throws { let payload = Data(repeating: 0x41, count: 64 * 1024) var framedPaste = Data([0x1B, 0x5B, 0x32, 0x30, 0x30, 0x7E]) From 21fbd25473bcd29ac4e3e606ee1e34eded1d672f Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Fri, 17 Jul 2026 18:58:21 +0900 Subject: [PATCH 10/10] docs: describe coding agent workspace --- ARCHITECTURE.md | 14 ++++++++++---- CONTRIBUTING.md | 4 +++- DESIGN.md | 48 +++++++++++++++++++++++------------------------- README.md | 38 +++++++++++++++++++++----------------- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a5602af..4b1cdb5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,6 +7,7 @@ responsibility rather than by framework or build order. ```text Application/ App lifecycle and display selection Companion/ Idle/terminal presentation state and transparent panel +Agent/ Coding-agent request construction and delivery state Terminal/ Core/ ANSI parsing, buffer, paging, input encoding, value types PTY/ Pseudoterminal process and non-blocking I/O boundary @@ -19,14 +20,19 @@ Assets/ Processed application and companion artwork Dependency direction is intentionally one-way: ```text -Application + Companion -> Terminal Rendering -> Terminal Workspace/Session -> PTY + Core +Application + Companion -> Agent + Terminal Rendering -> Terminal Workspace/Session -> PTY + Core ``` -`TerminalWorkspaceModel` is the only owner of tab/split topology. Every leaf -maps to exactly one `TerminalSessionModel` and one `TerminalSessionID`-derived -persistence file; views route focus and raw input to that leaf only. It never +`MikuPanelCoordinator` owns one `TerminalSessionModel` for the one visible +embedded PTY. Views route focus and raw input to that session only; it never creates another `NSWindow`. +`AgentWorkspaceModel` is the UI-side owner of a coding-agent request. It +constructs only supported provider/approval commands, hands them to the active +`TerminalSessionModel`, and records the request in conversation history only +after the PTY reports delivery for the matching request token. It does not own +or create a second PTY, panel, or window. + `Terminal/Core` contains deterministic terminal behavior and must not import SwiftUI, AppKit, Metal, or process-launch concerns. UI and process boundaries belong to their named directories; this keeps parser and buffer changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72b6986..6dc439f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,8 @@ # Contributing to MikuCode -Thanks for helping improve MikuCode. Contributions should make the macOS single-session terminal and companion UI clearer, more reliable, or more accessible while keeping its focused scope. +Thanks for helping improve MikuCode. Contributions should make the macOS +coding-agent workspace, local embedded terminal, or companion UI clearer, +more reliable, or more accessible while keeping its focused one-panel scope. This private repository requires a GitHub-authorized account for cloning and contributing. diff --git a/DESIGN.md b/DESIGN.md index 6518786..8e03876 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -14,8 +14,8 @@ the Dock. Development `swift run` output is not part of the product UI. | --- | --- | --- | | `idle` | Transparent panel; a centered front-facing chibi Miku stays still, changes to attentive eye contact on hover, and reveals a terminal bubble | Stopped | | `opening` | Same panel transitions to the active composition | Stopped | -| `terminal` | Screen-sized, borderless, normal-level panel with a large speech-bubble local-terminal workspace, compact MikuCode header, and Miku right rail | Each visible leaf is running and the focused pane is directly focused | -| `closing` | Active composition leaves the same panel | PTYs stopped, drained, and persisted | +| `terminal` | Screen-sized, borderless, normal-level panel with a large dark sky-blue speech-bubble coding-agent workspace, compact MikuCode header, and Miku right rail | The coordinator-owned local PTY runs whether its embedded view is shown or hidden | +| `closing` | Active composition leaves the same panel | The local PTY stops, drains, and persists | Repeated or out-of-order events are no-ops. Command-W and panel-close requests use the close transition and return to `idle`. @@ -33,23 +33,22 @@ use the close transition and return to `idle`. reveals a small speech-bubble terminal affordance. The companion never walks, slides, or bobs in idle; only the intentional gaze state changes. Clicking either Miku herself or that bubble begins the terminal transition. -- In terminal state, the bubble header contains `miku-code`, a horizontally - scrollable selected-tab strip, the actual focused-PTY state, and one close - control. `Command-T` creates a tab, `Command-D` splits the focused pane to - the right, and `Shift-Command-D` splits it below; these actions retain the - one-panel, one-bubble composition. Hovering expanded Miku reveals a close +- In terminal state, the bubble opens as a coding-agent workspace: the centered + MikuCode mark gives way to prompt history, a provider/approval composer, and + a terminal toggle. Codex and Claude support `Plan`, `Accept`, and `Auto`; + the workspace only records a request after its token-matched PTY delivery. + The terminal toggle reveals that same local PTY inside the bubble—never a + second window. Hovering expanded Miku reveals a close marker. Clicking either close target stops the terminal and returns the same running app to its small idle companion at the floating layer; it only returns behind other apps after the app itself loses focus, and never quits MikuCode. -- The terminal header is an agent-workspace cue, not a toolbar: it contains the - app name, tab navigation, the actual focused-PTY state, and one close - control. There are no traffic - lights, provider selectors, paging/search controls, or permanent Clear, - Interrupt, or Send buttons. Command-F opens the only transient text field: a - compact in-bubble terminal finder. Command-G and Shift-Command-G navigate - its case-insensitive matches across output pages; Escape restores terminal - focus and removes the finder. +- The workspace header is an agent-workspace cue, not a toolbar: it contains + the app name, an embedded-terminal toggle, and one close control. The + composer is the only persistent input surface. Command-F opens the only + transient terminal text field: a compact in-bubble finder. Command-G and + Shift-Command-G navigate its case-insensitive matches across output pages; + Escape restores terminal focus and removes the finder. - The speech bubble resizes directly from its edges and corners. Its frame stays inside a 24-point display inset and preserves a minimum right-side Miku rail; native directional cursors expose the otherwise unobtrusive resize targets. @@ -66,21 +65,20 @@ event monitor is used. PTY output is read without blocking, input is bounded and drained through a write source, resize is sent through `TIOCSWINSZ`, and shutdown sends `SIGHUP` -with a bounded `SIGKILL` fallback. Each workspace leaf owns its own PTY and -bounded snapshot; the tab/split/focus layout and per-session snapshots persist -on close and restore on the next launch. +with a bounded `SIGKILL` fallback. The coordinator-owned PTY and its bounded +snapshot persist on close and restore on the next launch. ## Visual tokens The visual system uses a 4-point spacing base. Idle is transparent; active -surfaces use a graphite two-stop bubble, a translucent rim, a restrained teal -shadow, and white terminal text. The header uses compact rounded app type with -monospaced `agent terminal` and PTY state labels, making the surface read as a -coding-agent workspace rather than a generic terminal emulator. The app icon is a separate +surfaces use flat dark sky-blue tones, a translucent cyan rim, a restrained +teal shadow, and white terminal text. The header uses compact rounded app type +with small monospaced agent labels, making the surface read as a coding-agent +workspace rather than a generic terminal emulator. The app icon is a separate terminal-and-Miku asset; the desktop companion contains only the 2D Miku character. Idle artwork stays centered and still above a subtle teal ground shadow. Its only idle motion is an intentional crossfade to an attentive gaze on hover; opening, active terminal, and closing states use the focus frame as -the deliberate zoom transition. ANSI colors -are confined to terminal output. Reduce Motion removes companion float and -state transitions while preserving state changes. +the deliberate zoom transition. ANSI colors are confined to terminal output. +Reduce Motion removes companion float and state transitions while preserving +state changes. diff --git a/README.md b/README.md index d55ada1..bc447e0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # MikuCode -MikuCode is a macOS menu-bar-style companion that becomes a focused local PTY -terminal in place. One transparent, persistent `NSPanel` owns both states: -the idle panel shows Miku on the display under the pointer, and the active panel -fills the display with a speech-bubble terminal and Miku on the right. +MikuCode is a macOS coding-agent desktop with an integrated local PTY. One +transparent, persistent `NSPanel` owns both states: the idle panel shows Miku +on the display under the pointer, and the active panel fills the display with a +dark sky-blue speech-bubble workspace and Miku on the right. ## Behavior @@ -11,15 +11,18 @@ fills the display with a speech-bubble terminal and Miku on the right. - Miku stays still and front-facing in her idle area. Hovering crossfades to an attentive gaze and reveals a small terminal speech bubble; selecting either Miku or that bubble opens the full terminal in the same panel. -- The expanded terminal uses the default MikuCode theme with a compact - `miku-code` header, a horizontally scrollable tab strip, and one close - button. `Command-T` opens a new local shell tab; `Command-D` splits the - focused terminal to the right; `Shift-Command-D` splits it below. Each leaf - owns an independent PTY, transcript, current directory, and persisted - session snapshot. Selecting the expanded Miku or the - close button returns the same running app to its small idle Miku without +- The expanded workspace uses the default MikuCode theme with a compact + `miku-code` header, one embedded-terminal toggle, and one close button. The + coordinator owns one local PTY, transcript, current directory, and persisted + session snapshot. Selecting the expanded Miku or the close button returns + the same running app to its small idle Miku without sending it behind other windows; it returns to the desktop layer only after the app loses focus, and never quits MikuCode. +- The agent workspace starts with a centered MikuCode mark and a focused + prompt composer. Choose Codex or Claude plus `Plan`, `Accept`, or `Auto`; + the request is written to the integrated PTY only after the local terminal + can accept it. The terminal button expands or hides that same embedded PTY + below the conversation—no second window is created. - Printable keys, control sequences, paste, resize, paging, find, clear, and interrupt use terminal keyboard actions. Command-F opens a compact transient finder inside the terminal bubble; Command-G and Shift-Command-G move between @@ -44,9 +47,9 @@ in [ARCHITECTURE.md](ARCHITECTURE.md). - `MikuPanelCoordinator` retains the single panel and applies the presentation state machine. -- `TerminalWorkspaceModel` owns tabs, focused-pane splits, per-session - storage, and workspace restoration while retaining one outer speech bubble. -- `TerminalPanel` embeds an individual terminal pane. +- `AgentWorkspaceModel` owns provider/approval selection and only records an + agent prompt after its PTY write is confirmed. +- `TerminalPanel` embeds the coordinator-owned terminal pane. - `TerminalSessionModel` owns one PTY lifecycle, buffering, persistence, and terminal updates. - `PTYProcess` uses `forkpty`, a bounded non-blocking output/input path, and @@ -73,9 +76,10 @@ Requirements: macOS 14 or later, Swift 6 or later, and a local POSIX shell. ## Scope -MikuCode provides local shell tabs and focused-pane splits inside one panel. -Remote connections, profiles, SSH, editor features, tab closing/reordering, -and complete terminal compatibility are out of scope. The protocol +MikuCode provides one local shell and coding-agent prompts inside one panel. +Remote connections, profiles, SSH, editor features, terminal tabs/splits, +provider-specific session restoration, and complete terminal compatibility are +out of scope. The protocol intentionally covers a focused subset of UTF-8, ANSI SGR, cursor movement, erasure, and bracketed paste behavior.