Skip to content
Merged
Binary file added -l
Binary file not shown.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ coverage.profdata
coverage.json
test-results/
tmp/

# OMC operational state (local only)
.omc/
132 changes: 122 additions & 10 deletions Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ enum AgentProvider: String, CaseIterable, Identifiable {

var tint: Color {
switch self {
case .codex: Color(red: 0.720, green: 0.780, blue: 0.860)
case .claude: Color(red: 1.000, green: 0.590, blue: 0.300)
case .codex: Color(red: 0.880, green: 0.905, blue: 0.930)
case .claude: Color(red: 0.851, green: 0.467, blue: 0.341)
case .pi: Color(red: 0.740, green: 0.520, blue: 1.000)
case .openCode: Color(red: 0.400, green: 0.880, blue: 0.650)
}
Expand Down Expand Up @@ -81,8 +81,14 @@ struct AgentRunRequest: Equatable {
init?(provider: AgentProvider, approval: AgentApproval, prompt: String) {
guard provider.supportsApproval else { return nil }
guard !prompt.isEmpty else { return nil }
// Allow multi-line prompts: newline (0x0A) and tab (0x09) are the only
// control characters the multi-line composer can produce and both stay
// literal inside POSIX single-quote wrapping. Every other control/escape
// byte (e.g. Ctrl-C, ESC) remains rejected so it cannot reach the PTY.
guard prompt.unicodeScalars.allSatisfy({ scalar in
scalar.value >= 0x20 && scalar.value != 0x7F
(scalar.value >= 0x20 && scalar.value != 0x7F)
|| scalar.value == 0x0A
|| scalar.value == 0x09
}) else {
return nil
}
Expand Down Expand Up @@ -131,6 +137,11 @@ struct LocalPTYAgentRunner {
final class AgentWorkspaceModel: ObservableObject {
typealias RequestStarter = (TerminalSessionModel, AgentRunRequest) -> TerminalAgentInputSubmission?

enum RunLifecycle: Equatable {
case idle
case running
}

@Published var provider = AgentProvider.codex
@Published var approval = AgentApproval.accept
@Published var prompt = ""
Expand All @@ -139,15 +150,47 @@ final class AgentWorkspaceModel: ObservableObject {
private(set) var pendingPromptDeliveryToken: UUID?
@Published var isTerminalPresented = false
@Published private(set) var submissionError: String?
@Published private(set) var runLifecycle: RunLifecycle = .idle
/// Fires after a prompt lands in the transcript so the session store can
/// persist thread titles/history.
var onTranscriptChanged: (() -> Void)?
private let startRequest: RequestStarter
private let isForegroundCommandRunning: (TerminalSessionModel) -> Bool
private weak var runTerminalSession: TerminalSessionModel?
// Guards the run-start race: right after the command is written the shell is
// still the foreground group (it has not forked the agent yet). We only treat
// a return-to-shell as completion once we have actually observed the agent
// take the foreground at least once.
private var runObservedForegroundCommand = false
// If the agent never takes the foreground within this grace period (e.g. the
// binary is missing and the shell printed "command not found"), the run is
// resolved on the next flush instead of locking the composer forever.
private let runStartupGrace: Duration
private var runStartInstant: ContinuousClock.Instant?

init(startRequest: @escaping RequestStarter = { terminalSession, request in
LocalPTYAgentRunner(terminalSession: terminalSession).start(request)
}) {
init(
startRequest: @escaping RequestStarter = { terminalSession, request in
LocalPTYAgentRunner(terminalSession: terminalSession).start(request)
},
isForegroundCommandRunning: @escaping (TerminalSessionModel) -> Bool = { session in
session.isForegroundCommandRunning()
},
runStartupGrace: Duration = .milliseconds(500)
) {
self.startRequest = startRequest
self.isForegroundCommandRunning = isForegroundCommandRunning
self.runStartupGrace = runStartupGrace
}

func submit(to terminalSession: TerminalSessionModel) {
// A run must finish (or its queued write must be resolved) before the next
// prompt may be submitted; otherwise prompt B is written into the busy PTY
// while agent A still owns it (swallowed by a REPL, or — with codex exec —
// run as a shell command after codex exits).
guard runLifecycle == .idle, pendingPrompt == nil else {
submissionError = "An agent run is already in progress."
return
}
let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard let request = AgentRunRequest(provider: provider, approval: approval, prompt: text) else {
submissionError = provider.supportsApproval
Expand All @@ -158,6 +201,10 @@ final class AgentWorkspaceModel: ObservableObject {
terminalSession.onInputDeliveryUpdate = { [weak self] update in
self?.receiveInputDelivery(update)
}
terminalSession.onOutputFlush = { [weak self] in
self?.evaluateRunCompletion()
}
runTerminalSession = terminalSession
let agentPrompt = AgentPrompt(provider: provider, approval: approval, text: text)
guard let submission = startRequest(terminalSession, request) else {
submissionError = terminalSession.isRunning
Expand All @@ -168,6 +215,8 @@ final class AgentWorkspaceModel: ObservableObject {
switch submission.delivery {
case .delivered:
submittedPrompts.append(agentPrompt)
onTranscriptChanged?()
beginRun(on: terminalSession)
case .queued:
pendingPrompt = agentPrompt
pendingPromptDeliveryToken = submission.deliveryToken
Expand All @@ -177,6 +226,19 @@ final class AgentWorkspaceModel: ObservableObject {
isTerminalPresented = true
}

/// Rebuilds the transcript from persisted thread state. Only meaningful on a
/// freshly created model (restore happens before any live submission).
func restoreTranscript(
prompts: [AgentPrompt],
provider: AgentProvider,
approval: AgentApproval
) {
guard submittedPrompts.isEmpty, pendingPrompt == nil else { return }
submittedPrompts = prompts
self.provider = provider
self.approval = approval
}

var isAwaitingPromptDelivery: Bool {
pendingPrompt != nil
}
Expand All @@ -185,21 +247,71 @@ final class AgentWorkspaceModel: ObservableObject {
pendingPrompt == nil
}

/// True when a new prompt may be submitted: no queued write in flight and no
/// agent run currently owning the PTY.
var isRunGateOpen: Bool {
pendingPrompt == nil && runLifecycle == .idle
}

private func receiveInputDelivery(_ update: TerminalInputDeliveryUpdate) {
guard let pendingPrompt else { return }
switch update {
case let .delivered(deliveryToken):
guard deliveryToken == pendingPromptDeliveryToken else { return }
guard let pendingPrompt, deliveryToken == pendingPromptDeliveryToken else { return }
submittedPrompts.append(pendingPrompt)
onTranscriptChanged?()
self.pendingPrompt = nil
pendingPromptDeliveryToken = nil
submissionError = nil
if let runTerminalSession {
beginRun(on: runTerminalSession)
}
case let .failed(deliveryToken, _):
guard deliveryToken == pendingPromptDeliveryToken else { return }
guard let pendingPrompt, deliveryToken == pendingPromptDeliveryToken else { return }
failPendingPrompt(pendingPrompt)
case .sessionEnded:
failPendingPrompt(pendingPrompt)
if let pendingPrompt {
failPendingPrompt(pendingPrompt)
}
finishRun()
}
}

private func beginRun(on terminalSession: TerminalSessionModel) {
runTerminalSession = terminalSession
runObservedForegroundCommand = false
runStartInstant = ContinuousClock.now
runLifecycle = .running
}

// Completion detection (single mechanism): the PTY foreground process group.
// While the agent runs it owns the terminal's foreground group; when it exits
// the shell reclaims the foreground and redraws its prompt — that prompt is
// output, so this is re-evaluated on the next flush and the run resolves.
private func evaluateRunCompletion() {
guard runLifecycle == .running, let session = runTerminalSession else { return }
if isForegroundCommandRunning(session) {
runObservedForegroundCommand = true
return
}
// Foreground is the shell. Only a completion once the agent had actually
// taken the foreground; before that it simply has not launched yet — unless
// the startup grace has elapsed, meaning the agent never launched at all
// (e.g. command not found) and the run must resolve rather than lock the gate.
guard !runObservedForegroundCommand else {
finishRun()
return
}
if let runStartInstant, ContinuousClock.now - runStartInstant > runStartupGrace {
finishRun()
}
}

private func finishRun() {
guard runLifecycle != .idle else { return }
runLifecycle = .idle
runObservedForegroundCommand = false
runStartInstant = nil
runTerminalSession = nil
}

private func failPendingPrompt(_ pendingPrompt: AgentPrompt) {
Expand Down
22 changes: 22 additions & 0 deletions Sources/MikuCodeApp/Agent/WorkspaceFolderPicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import AppKit

/// Directory chooser for new threads. `chooseOverride` is a test seam so unit
/// tests never present a real panel.
@MainActor
enum WorkspaceFolderPicker {
static var chooseOverride: (() -> URL?)?

static func choose() -> URL? {
if let chooseOverride {
return chooseOverride()
}
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Choose"
panel.message = "Choose the folder this thread works in"
return panel.runModal() == .OK ? panel.url : nil
}
}
Loading