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. diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift new file mode 100644 index 0000000..4139f26 --- /dev/null +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -0,0 +1,186 @@ +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) -> TerminalAgentInputSubmission? { + guard terminalSession.isRunning else { return nil } + return terminalSession.submitAgentCommand(request.shellCommand) + } +} + +@MainActor +final class AgentWorkspaceModel: ObservableObject { + 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 + + 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) + 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 + } + terminalSession.onInputDeliveryUpdate = { [weak self] update in + self?.receiveInputDelivery(update) + } + let agentPrompt = AgentPrompt(provider: provider, approval: approval, text: text) + guard let submission = startRequest(terminalSession, request) else { + submissionError = terminalSession.isRunning + ? "The local terminal could not accept this prompt." + : "The local terminal is still opening." + return + } + switch submission.delivery { + case .delivered: + submittedPrompts.append(agentPrompt) + case .queued: + pendingPrompt = agentPrompt + pendingPromptDeliveryToken = submission.deliveryToken + } + prompt = "" + submissionError = nil + isTerminalPresented = true + } + + var isAwaitingPromptDelivery: Bool { + pendingPrompt != nil + } + + var isComposerInteractionEnabled: Bool { + pendingPrompt == nil + } + + private func receiveInputDelivery(_ update: TerminalInputDeliveryUpdate) { + guard let pendingPrompt else { return } + switch update { + case let .delivered(deliveryToken): + guard deliveryToken == pendingPromptDeliveryToken else { return } + submittedPrompts.append(pendingPrompt) + self.pendingPrompt = nil + pendingPromptDeliveryToken = nil + submissionError = nil + 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 + provider = pendingPrompt.provider + approval = pendingPrompt.approval + prompt = pendingPrompt.text + submissionError = "The queued prompt was not delivered." + } +} + +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 new file mode 100644 index 0000000..2308315 --- /dev/null +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -0,0 +1,274 @@ +import SwiftUI + +@MainActor +struct AgentWorkspaceView: View { + @ObservedObject var terminalSession: TerminalSessionModel + @ObservedObject var workspace: AgentWorkspaceModel + let terminalFocusRequestID: Int + let onTerminalFocus: () -> Void + let onClose: () -> Void + let onResize: (TerminalBubbleResizeEdge, CGSize) -> Void + let onResizeEnded: () -> Void + let rendererPolicy: TerminalRendererPolicy + @FocusState private var isPromptFocused: Bool + + var body: some View { + VStack(spacing: 0) { + header + conversation + composer + if workspace.isTerminalPresented { + embeddedTerminal + } + } + .background(AgentWorkspacePalette.surface) + .clipShape(SpeechBubbleShape()) + .overlay { SpeechBubbleShape().stroke(AgentWorkspacePalette.rim, lineWidth: 1) } + .overlay { + TerminalResizeHandles(onResize: onResize, onResizeEnded: onResizeEnded) + } + .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)) { + 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(workspace.isTerminalPresented ? AgentWorkspacePalette.teal : AgentWorkspacePalette.muted) + .background(AgentWorkspacePalette.control) + .clipShape(Circle()) + .accessibilityLabel(workspace.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 workspace.submittedPrompts.isEmpty, workspace.pendingPrompt == nil { + VStack(alignment: .leading, spacing: 8) { + 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) + .padding(.top, 44) + } else { + ForEach(workspace.submittedPrompts) { prompt in + VStack(alignment: .leading, spacing: 8) { + Text("you · \(prompt.provider.title) · \(prompt.approval.title)") + .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)) + } + 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 { + Text(error) + .font(.system(size: 11, design: .rounded)) + .foregroundStyle(Color.red.opacity(0.92)) + } + } + .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) { workspace.provider = option } + .disabled(!option.supportsApproval) + } + } label: { + AgentWorkspacePill(title: workspace.provider.title) + } + .menuStyle(.borderlessButton) + + Menu { + ForEach(AgentApproval.allCases) { option in + Button(option.title) { workspace.approval = option } + } + } label: { + AgentWorkspacePill(title: workspace.approval.title) + } + .menuStyle(.borderlessButton) + Spacer() + } + + HStack(alignment: .bottom, spacing: 10) { + TextField("Ask the coding agent…", text: $workspace.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(!canSubmit) + .opacity(canSubmit ? 1 : 0.38) + .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) } + .disabled(!workspace.isComposerInteractionEnabled) + } + + private var embeddedTerminal: some View { + TerminalPanel( + session: terminalSession, + focusRequestID: terminalFocusRequestID, + onFocus: onTerminalFocus, + 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) } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onTapGesture(perform: onTerminalFocus) + } + + private var canSubmit: Bool { + terminalSession.isRunning + && workspace.provider.supportsApproval + && !workspace.isAwaitingPromptDelivery + && !workspace.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func submit() { + guard canSubmit else { return } + workspace.submit(to: terminalSession) + onTerminalFocus() + } +} + +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 + + 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.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/Application/MikuCodeApp.swift b/Sources/MikuCodeApp/Application/MikuCodeApp.swift index f9b3b3b..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 @@ -154,41 +154,20 @@ 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, + workspace: agentWorkspace, + terminalFocusRequestID: presentation.focusRequestID, + onTerminalFocus: 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) Button(action: onClose) { ActiveMikuCloseTarget(isHovering: isActiveMikuHovering) } diff --git a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift index bcb89a7..121ec3f 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)? @@ -288,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 a5619b0..1a88532 100644 --- a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift +++ b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift @@ -6,7 +6,9 @@ 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 InputErrorHandler = @Sendable (UUID?, TerminalSessionError) -> Void + typealias InputDeliveredHandler = @Sendable (UUID) -> Void + typealias BeforeInputDrainHandler = @Sendable () -> Void typealias WriteOperation = @Sendable (Int32, UnsafeRawBufferPointer) -> PTYWriteResult static let outboundCapacity = 1_048_576 @@ -34,10 +36,13 @@ 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) private var pendingInput = PTYOutboundBuffer(capacity: PTYProcess.outboundCapacity) + private var pendingDeliveryToken: UUID? private var writeSourceResumed = false private var writeSourceCancelled = false private var readSourceResumed = true @@ -50,7 +55,10 @@ final class PTYProcess: @unchecked Sendable { outputCapacity: @escaping OutputCapacityHandler = { .max }, onOutput: @escaping OutputHandler, onExit: @escaping ExitHandler, - onInputError: @escaping InputErrorHandler = { _ in } + onInputError: @escaping InputErrorHandler = { _, _ in }, + onInputDelivered: @escaping InputDeliveredHandler = { _ in }, + beforeInputDrain: @escaping BeforeInputDrainHandler = {}, + writeOperation: WriteOperation? = nil ) throws -> PTYProcess { guard let shellPath = strdup(shell), let loginArgument = strdup("-l") @@ -122,7 +130,9 @@ final class PTYProcess: @unchecked Sendable { outputCapacity: outputCapacity, onExit: onExit, onInputError: onInputError, - writeOperation: Self.systemWrite + onInputDelivered: onInputDelivered, + beforeInputDrain: beforeInputDrain, + writeOperation: writeOperation ?? Self.systemWrite ) } @@ -133,6 +143,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 @@ -141,6 +153,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 @@ -161,7 +175,7 @@ final class PTYProcess: @unchecked Sendable { Darwin.close(masterFileDescriptor) } writeSource.setEventHandler { [weak self] in - self?.drainPendingInput() + self?.drainPendingInputFromWriteSource() } readSource.resume() monitorExit() @@ -171,13 +185,35 @@ final class PTYProcess: @unchecked Sendable { stopAndDrain() } - func write(_ data: Data) throws { + @discardableResult + func write( + _ data: Data, + deliveryToken: UUID? = nil + ) throws -> TerminalInputDelivery { guard !data.isEmpty else { throw TerminalSessionError.emptyInput } - try syncOnQueue { + return try syncOnQueue { guard isProcessActive else { throw TerminalSessionError.notRunning } try pendingInput.enqueue(data) - drainPendingInput() + beforeInputDrain() + 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 + } + } else if let pendingDeliveryToken { + self.pendingDeliveryToken = nil + onInputDelivered(pendingDeliveryToken) + } + return delivery } } @@ -307,18 +343,41 @@ 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 drainPendingInput() { + private func drainPendingInputFromWriteSource() { + do { + if try drainPendingInput(throwWhenInactive: false) == .delivered, + let deliveryToken = pendingDeliveryToken { + pendingDeliveryToken = nil + onInputDelivered(deliveryToken) + } + } catch let error as TerminalSessionError { + reportInputError(error) + } catch { + reportInputError(.systemCall("write", EIO)) + } + } + + private func reportInputError(_ error: TerminalSessionError) { + let deliveryToken = pendingDeliveryToken + pendingDeliveryToken = nil + onInputError(deliveryToken, error) + } + + 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 @@ -328,17 +387,19 @@ final class PTYProcess: @unchecked Sendable { } if result == .wouldBlock { resumeWriteSource() + return .queued } else { suspendWriteSource() + return .delivered } } 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) } } @@ -356,6 +417,7 @@ final class PTYProcess: @unchecked Sendable { private func cancelSourcesAndPendingInput() { pendingInput.removeAll() + pendingDeliveryToken = nil guard !writeSourceCancelled else { return } if !writeSourceResumed { writeSource.resume() @@ -417,6 +479,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 310ac25..1b05dc9 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() @@ -85,6 +86,18 @@ final class TerminalSessionModel: ObservableObject { self?.flushPendingOutput() self?.didExit(status: status) } + }, + onInputError: { [weak self] deliveryToken, error in + Task { @MainActor [weak self] in + guard self?.generation == currentGeneration else { return } + self?.didReceiveInputError(deliveryToken: deliveryToken, error: error) + } + }, + onInputDelivered: { [weak self] deliveryToken in + Task { @MainActor [weak self] in + guard self?.generation == currentGeneration else { return } + self?.didDeliverQueuedInput(deliveryToken: deliveryToken) + } } ) try lifecycle.start() @@ -98,8 +111,20 @@ final class TerminalSessionModel: ObservableObject { } } - func submit(_ line: String) { - sendRawInput(Data((line + "\n").utf8)) + @discardableResult + func submit(_ line: String) -> Bool { + writeInput(Data((line + "\n").utf8)) != nil + } + + 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() { @@ -107,13 +132,27 @@ final class TerminalSessionModel: ObservableObject { } func sendRawInput(_ data: Data) { + _ = writeInput(data) + } + + private func writeInput( + _ data: Data, + deliveryToken: UUID? = nil + ) -> TerminalInputDelivery? { do { try lifecycle.validateInput(data) - try process?.write(data) + guard let process else { + statusText = "INPUT ERROR" + appendSystemMessage("Terminal process is unavailable.") + return nil + } + let delivery = try process.write(data, deliveryToken: deliveryToken) updateWorkingDirectory(from: data) + return delivery } catch { statusText = "INPUT ERROR" appendSystemMessage(error.localizedDescription) + return nil } } @@ -168,6 +207,7 @@ final class TerminalSessionModel: ObservableObject { isRunning = false isBracketedPasteEnabled = false statusText = "STOPPED" + onInputDeliveryUpdate?(.sessionEnded) } private func flushPendingOutput() { @@ -194,6 +234,23 @@ final class TerminalSessionModel: ObservableObject { statusText = status == 0 ? "EXITED" : "EXIT \(status)" appendSystemMessage("Shell exited with status \(status).") saveSession() + onInputDeliveryUpdate?(.sessionEnded) + } + + private func didReceiveInputError( + deliveryToken: UUID?, + error: TerminalSessionError + ) { + guard isRunning else { return } + statusText = "INPUT ERROR" + appendSystemMessage(error.localizedDescription) + if let deliveryToken { + onInputDeliveryUpdate?(.failed(deliveryToken, error)) + } + } + + private func didDeliverQueuedInput(deliveryToken: UUID) { + onInputDeliveryUpdate?(.delivered(deliveryToken)) } private func appendSystemMessage(_ message: String) { @@ -285,6 +342,17 @@ final class TerminalSessionModel: ObservableObject { private static let maximumTrackedInputBytes = 4 * 1024 } +enum TerminalInputDeliveryUpdate: Equatable { + case delivered(UUID) + case failed(UUID, TerminalSessionError) + case sessionEnded +} + +struct TerminalAgentInputSubmission: Equatable { + let delivery: TerminalInputDelivery + let deliveryToken: UUID +} + struct PendingTerminalOutputBatch: Sendable { let chunks: [Data] let hasDiscontinuity: Bool diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift new file mode 100644 index 0000000..123def9 --- /dev/null +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import MikuCodeApp + +@MainActor +final class AgentRunRequestTests: XCTestCase { + 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() throws { + let request = try XCTUnwrap(AgentRunRequest( + provider: .claude, + approval: .accept, + prompt: "make the change" + )) + + XCTAssertEqual( + request.shellCommand, + "claude --permission-mode acceptEdits 'make the change'" + ) + } + + 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) + + XCTAssertTrue(workspace.submittedPrompts.isEmpty) + XCTAssertEqual(workspace.submissionError, "The local terminal is still opening.") + } + + func testQueuedPromptIsRecordedOnlyAfterTerminalDelivery() throws { + let workspace = AgentWorkspaceModel(startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) + }) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "wait for capacity" + + workspace.submit(to: terminal) + + 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)) + + XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["wait for capacity"]) + XCTAssertNil(workspace.pendingPrompt) + XCTAssertFalse(workspace.isAwaitingPromptDelivery) + XCTAssertTrue(workspace.isComposerInteractionEnabled) + } + + func testQueuedPromptIsRestoredWhenTerminalRejectsItLater() throws { + let workspace = AgentWorkspaceModel(startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) + }) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "retry after failure" + + workspace.submit(to: terminal) + let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) + workspace.provider = .claude + workspace.approval = .auto + workspace.prompt = "new draft" + terminal.onInputDeliveryUpdate?(.failed(token, .systemCall("write", EIO))) + + XCTAssertTrue(workspace.submittedPrompts.isEmpty) + XCTAssertNil(workspace.pendingPrompt) + XCTAssertEqual(workspace.prompt, "retry after failure") + XCTAssertEqual(workspace.provider, .codex) + XCTAssertEqual(workspace.approval, .accept) + XCTAssertTrue(workspace.isComposerInteractionEnabled) + XCTAssertEqual(workspace.submissionError, "The queued prompt was not delivered.") + } + + 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/AppShellPolicyTests.swift b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift index 1c67d5a..89dfc18 100644 --- a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift +++ b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift @@ -168,6 +168,45 @@ 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 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 5ea23c8..256ae29 100644 --- a/Tests/MikuCodeAppTests/PTYProcessTests.swift +++ b/Tests/MikuCodeAppTests/PTYProcessTests.swift @@ -63,6 +63,94 @@ 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 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 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]) @@ -88,7 +176,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) } @@ -414,6 +502,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__" 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)] { 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)