diff --git a/-l b/-l new file mode 100644 index 0000000..5311204 Binary files /dev/null and b/-l differ diff --git a/.gitignore b/.gitignore index da5f550..b6b8735 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ coverage.profdata coverage.json test-results/ tmp/ + +# OMC operational state (local only) +.omc/ diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift index 4fead50..9b4a1e0 100644 --- a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -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) } @@ -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 } @@ -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 = "" @@ -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 @@ -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 @@ -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 @@ -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 } @@ -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) { diff --git a/Sources/MikuCodeApp/Agent/WorkspaceFolderPicker.swift b/Sources/MikuCodeApp/Agent/WorkspaceFolderPicker.swift new file mode 100644 index 0000000..d0afe09 --- /dev/null +++ b/Sources/MikuCodeApp/Agent/WorkspaceFolderPicker.swift @@ -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 + } +} diff --git a/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift b/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift new file mode 100644 index 0000000..c5dbbd5 --- /dev/null +++ b/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift @@ -0,0 +1,263 @@ +import Foundation + +/// One workspace thread: an agent transcript bound to its own local terminal, +/// so switching sessions swaps both together (codex-desktop style threads). +@MainActor +final class WorkspaceSessionEntry: ObservableObject, Identifiable { + let id: UUID + let createdAt: Date + /// Thread-specific working directory; nil uses the app default (~/Desktop). + @Published private(set) var workingDirectory: URL? + /// User-chosen thread name; nil falls back to the first prompt's first line. + @Published private(set) var customTitle: String? + let terminal: TerminalSessionModel + let agent: AgentWorkspaceModel + + func updateWorkingDirectory(_ url: URL) { + workingDirectory = url + } + + func rename(_ name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + customTitle = trimmed.isEmpty ? nil : String(trimmed.prefix(64)) + } + + init( + id: UUID = UUID(), + createdAt: Date = Date(), + workingDirectory: URL? = nil, + customTitle: String? = nil, + terminal: TerminalSessionModel, + agent: AgentWorkspaceModel = AgentWorkspaceModel() + ) { + self.id = id + self.createdAt = createdAt + self.workingDirectory = workingDirectory + self.customTitle = customTitle + self.terminal = terminal + self.agent = agent + } + + var title: String { + if let customTitle { return customTitle } + guard let first = agent.submittedPrompts.first else { return "New thread" } + let line = first.text.split(separator: "\n").first.map(String.init) ?? first.text + return String(line.prefix(48)) + } + + var abbreviatedWorkingDirectory: String? { + guard let workingDirectory else { return nil } + return (workingDirectory.path as NSString).abbreviatingWithTildeInPath + } +} + +/// Owns the workspace's session threads and persists them across app restarts. +/// The presentation state machine drives terminal lifecycles through this +/// store: opening the workspace starts the selected thread's shell lazily, +/// switching threads starts the newly selected one, and closing stops them all +/// (saving each terminal's snapshot). +@MainActor +final class WorkspaceSessionStore: ObservableObject { + @Published private(set) var sessions: [WorkspaceSessionEntry] = [] + @Published private(set) var selectedID: UUID? + /// Last folder the user picked; preselected for the next thread. + @Published private(set) var defaultWorkingDirectory: URL? + private let repository: WorkspaceSessionsRepository + private let shell: String? + private var isStarted = false + + init( + repository: WorkspaceSessionsRepository = WorkspaceSessionsRepository(), + initialTerminal: TerminalSessionModel? = nil, + shell: String? = nil + ) { + self.repository = repository + self.shell = shell + if let initialTerminal { + // Injected terminal (tests, previews): single deterministic thread. + let entry = WorkspaceSessionEntry(terminal: initialTerminal) + wire(entry) + sessions = [entry] + selectedID = entry.id + } else if let index = repository.loadIndex() { + restore(from: index) + } + } + + var selected: WorkspaceSessionEntry? { + sessions.first { $0.id == selectedID } + } + + @discardableResult + func createSession(workingDirectory: URL? = nil) -> WorkspaceSessionEntry { + let id = UUID() + let directory = workingDirectory ?? defaultWorkingDirectory + let terminal = TerminalSessionModel( + persistence: repository.terminalPersistence(for: id), + shell: shell, + initialWorkingDirectory: directory?.path + ) + let entry = WorkspaceSessionEntry( + id: id, + workingDirectory: directory, + terminal: terminal + ) + wire(entry) + sessions.append(entry) + selectedID = entry.id + if let directory { + defaultWorkingDirectory = directory + } + if isStarted { + entry.terminal.start() + } + persist() + return entry + } + + func select(_ id: UUID) { + guard let entry = sessions.first(where: { $0.id == id }) else { return } + selectedID = id + if isStarted { + entry.terminal.start() + } + persist() + } + + func setDefaultWorkingDirectory(_ url: URL) { + defaultWorkingDirectory = url + persist() + } + + /// Change a thread's working directory. The new folder is persisted, becomes + /// the default for the next thread, and — when the thread's shell is running + /// and no agent run is in flight — the live shell is moved there with `cd` + /// so the next run actually executes in the chosen folder. + func changeWorkingDirectory(_ url: URL, for id: UUID) { + guard let entry = sessions.first(where: { $0.id == id }) else { return } + entry.updateWorkingDirectory(url) + defaultWorkingDirectory = url + if entry.terminal.isRunning, entry.agent.isRunGateOpen { + let quoted = "'\(url.path.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + _ = entry.terminal.submitAgentCommand("cd \(quoted)") + } + persist() + } + + func renameSession(_ id: UUID, to name: String) { + guard let entry = sessions.first(where: { $0.id == id }) else { return } + entry.rename(name) + persist() + } + + /// Remove a thread: stop its shell, delete its persisted terminal snapshot, + /// and move the selection to a neighboring thread (or none). + func deleteSession(_ id: UUID) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + let entry = sessions[index] + entry.terminal.stop() + repository.deleteTerminalSnapshot(for: id) + sessions.remove(at: index) + if selectedID == id { + let neighbor = index < sessions.count ? sessions[index] : sessions.last + selectedID = neighbor?.id + if isStarted, let neighbor { + neighbor.terminal.start() + } + } + persist() + } + + func persist() { + let index = PersistedWorkspaceSessionIndex( + selectedID: selectedID, + defaultWorkingDirectory: defaultWorkingDirectory?.path, + sessions: sessions.map { entry in + PersistedWorkspaceSession( + id: entry.id, + createdAt: entry.createdAt, + workingDirectory: entry.workingDirectory?.path, + customTitle: entry.customTitle, + provider: entry.agent.provider.rawValue, + approval: entry.agent.approval.rawValue, + prompts: entry.agent.submittedPrompts.map { + PersistedWorkspacePrompt( + provider: $0.provider.rawValue, + approval: $0.approval.rawValue, + text: $0.text + ) + } + ) + } + ) + do { + try repository.save(index) + } catch { + FileHandle.standardError.write( + Data("[miku] Workspace sessions save failed: \(error)\n".utf8) + ) + } + } + + private func restore(from index: PersistedWorkspaceSessionIndex) { + defaultWorkingDirectory = index.defaultWorkingDirectory.map { + URL(fileURLWithPath: $0, isDirectory: true) + } + sessions = index.sessions.map { persisted in + let workingDirectory = persisted.workingDirectory.map { + URL(fileURLWithPath: $0, isDirectory: true) + } + let terminal = TerminalSessionModel( + persistence: repository.terminalPersistence(for: persisted.id), + shell: shell, + initialWorkingDirectory: workingDirectory?.path + ) + let entry = WorkspaceSessionEntry( + id: persisted.id, + createdAt: persisted.createdAt, + workingDirectory: workingDirectory, + customTitle: persisted.customTitle, + terminal: terminal + ) + entry.agent.restoreTranscript( + prompts: persisted.prompts.map { + AgentPrompt( + provider: AgentProvider(rawValue: $0.provider) ?? .codex, + approval: AgentApproval(rawValue: $0.approval) ?? .accept, + text: $0.text + ) + }, + provider: AgentProvider(rawValue: persisted.provider) ?? .codex, + approval: AgentApproval(rawValue: persisted.approval) ?? .accept + ) + wire(entry) + return entry + } + if let selected = index.selectedID, sessions.contains(where: { $0.id == selected }) { + selectedID = selected + } else { + selectedID = sessions.first?.id + } + } + + private func wire(_ entry: WorkspaceSessionEntry) { + entry.agent.onTranscriptChanged = { [weak self] in + self?.persist() + } + } +} + +extension WorkspaceSessionStore: PresentationTerminalLifecycle { + /// Lazy start: only the selected thread's shell launches when the workspace + /// opens; other threads start when the user switches to them. + func start() { + isStarted = true + selected?.terminal.start() + } + + func stop() { + isStarted = false + sessions.forEach { $0.terminal.stop() } + persist() + } +} diff --git a/Sources/MikuCodeApp/Agent/WorkspaceSessionsRepository.swift b/Sources/MikuCodeApp/Agent/WorkspaceSessionsRepository.swift new file mode 100644 index 0000000..1f4fd30 --- /dev/null +++ b/Sources/MikuCodeApp/Agent/WorkspaceSessionsRepository.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Codable shape of one persisted workspace thread. The terminal's display +/// snapshot is stored separately per session (see `terminalPersistence(for:)`) +/// so the index stays small and cheap to rewrite. +struct PersistedWorkspaceSession: Codable, Equatable { + let id: UUID + let createdAt: Date + let workingDirectory: String? + /// Optional user-chosen name; decodes as nil from pre-rename index files. + let customTitle: String? + let provider: String + let approval: String + let prompts: [PersistedWorkspacePrompt] +} + +struct PersistedWorkspacePrompt: Codable, Equatable { + let provider: String + let approval: String + let text: String +} + +struct PersistedWorkspaceSessionIndex: Codable, Equatable { + let schemaVersion: Int + let selectedID: UUID? + let defaultWorkingDirectory: String? + let sessions: [PersistedWorkspaceSession] + + init( + selectedID: UUID?, + defaultWorkingDirectory: String?, + sessions: [PersistedWorkspaceSession] + ) { + schemaVersion = 1 + self.selectedID = selectedID + self.defaultWorkingDirectory = defaultWorkingDirectory + self.sessions = sessions + } +} + +/// Stores workspace threads under Application Support so sessions survive app +/// restarts (codex-desktop style): one `index.json` with thread metadata plus +/// one terminal snapshot file per thread. +struct WorkspaceSessionsRepository: Sendable { + private static let maximumEncodedIndexSize = 4 * 1024 * 1024 + + private let directoryURL: URL? + + init() { + let base = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first + directoryURL = base?.appendingPathComponent("MikuCode/sessions", isDirectory: true) + } + + /// Pass `nil` to disable persistence entirely (tests, previews). + init(directoryURL: URL?) { + self.directoryURL = directoryURL + } + + private var indexURL: URL? { + directoryURL?.appendingPathComponent("index.json") + } + + func loadIndex() -> PersistedWorkspaceSessionIndex? { + guard let indexURL else { return nil } + guard let data = try? Data(contentsOf: indexURL), + data.count <= Self.maximumEncodedIndexSize, + let index = try? Self.decoder.decode(PersistedWorkspaceSessionIndex.self, from: data), + index.schemaVersion == 1 else { + return nil + } + return index + } + + func save(_ index: PersistedWorkspaceSessionIndex) throws { + guard let indexURL, let directoryURL else { return } + let data = try Self.encoder.encode(index) + guard data.count <= Self.maximumEncodedIndexSize else { + throw TerminalSessionPersistenceError.oversizedEncodedSession + } + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + try data.write(to: indexURL, options: .atomic) + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: indexURL.path + ) + } + + /// Per-thread terminal snapshot storage, isolated per session id so threads + /// restore their own scrollback instead of sharing one global file. + func terminalPersistence(for id: UUID) -> TerminalSessionPersistence { + TerminalSessionPersistence( + url: directoryURL?.appendingPathComponent("\(id.uuidString).terminal.json") + ) + } + + /// Removes a deleted thread's snapshot so it cannot be resurrected and the + /// sessions directory does not accumulate orphans. + func deleteTerminalSnapshot(for id: UUID) { + guard let url = directoryURL?.appendingPathComponent("\(id.uuidString).terminal.json") else { + return + } + try? FileManager.default.removeItem(at: url) + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() +} diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index d2c6d9d..f7360ce 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -2,46 +2,84 @@ import SwiftUI @MainActor struct AgentWorkspaceView: View { - @ObservedObject var terminalSession: TerminalSessionModel - @ObservedObject var workspace: AgentWorkspaceModel + @ObservedObject var sessionStore: WorkspaceSessionStore + let selected: WorkspaceSessionEntry? 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 - @State private var isRimBreathing = false var body: some View { GeometryReader { proxy in - VStack(spacing: 0) { - header - conversation - composer(compact: proxy.size.width < AgentWorkspaceLayout.compactComposerWidth) - if workspace.isTerminalPresented { - embeddedTerminal(height: AgentWorkspaceLayout.terminalHeight(for: proxy.size.height)) + HStack(spacing: 0) { + SessionSidebar(store: sessionStore, onCreate: createThread) + if let selected { + SessionContentView( + entry: selected, + terminalSession: selected.terminal, + workspace: selected.agent, + workspaceHeight: proxy.size.height, + terminalFocusRequestID: terminalFocusRequestID, + onTerminalFocus: onTerminalFocus, + rendererPolicy: rendererPolicy, + onNewThread: createThread, + onChangeFolder: { changeFolder(for: selected) } + ) + .id(selected.id) + } else { + NewThreadPane(store: sessionStore) } } - .background(AgentWorkspacePalette.surface) + .background(AgentWorkspacePalette.surfaceGradient) .clipShape(SpeechBubbleShape()) - .overlay { SpeechBubbleShape().stroke(AgentWorkspacePalette.rim, lineWidth: 1) } - .overlay(alignment: .top) { animatedRim } - .overlay { - TerminalResizeHandles(onResize: onResize, onResizeEnded: onResizeEnded) - } - .shadow(color: .black.opacity(0.32), radius: 22, x: 0, y: 12) - } - .onAppear { - isPromptFocused = true - withAnimation(.easeInOut(duration: 1.8).repeatForever(autoreverses: true)) { - isRimBreathing = true - } + .overlay { SpeechBubbleShape().stroke(AgentWorkspacePalette.rimGradient, lineWidth: 1) } + .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 4) } .accessibilityElement(children: .contain) .accessibilityLabel("MikuCode coding agent workspace") } + /// Sidebar "+" reuses the last chosen folder, otherwise asks for one first. + private func createThread() { + if sessionStore.defaultWorkingDirectory != nil { + sessionStore.createSession() + } else if let picked = WorkspaceFolderPicker.choose() { + sessionStore.createSession(workingDirectory: picked) + } + } + + private func changeFolder(for entry: WorkspaceSessionEntry) { + guard let picked = WorkspaceFolderPicker.choose() else { return } + sessionStore.changeWorkingDirectory(picked, for: entry.id) + } + +} + +/// The conversation + composer + embedded terminal for one selected thread. +@MainActor +private struct SessionContentView: View { + @ObservedObject var entry: WorkspaceSessionEntry + @ObservedObject var terminalSession: TerminalSessionModel + @ObservedObject var workspace: AgentWorkspaceModel + let workspaceHeight: CGFloat + let terminalFocusRequestID: Int + let onTerminalFocus: () -> Void + let rendererPolicy: TerminalRendererPolicy + let onNewThread: () -> Void + let onChangeFolder: () -> Void + @FocusState private var isPromptFocused: Bool + + var body: some View { + VStack(spacing: 0) { + header + conversation + composer + if workspace.isTerminalPresented { + embeddedTerminal(height: AgentWorkspaceLayout.terminalHeight(for: workspaceHeight)) + } + } + .onAppear { isPromptFocused = true } + } + private var header: some View { HStack(spacing: 12) { MikuCodeLogo(compact: true) @@ -56,49 +94,55 @@ struct AgentWorkspaceView: View { workspace.isTerminalPresented.toggle() } if workspace.isTerminalPresented { + // Yield SwiftUI first responder so the terminal NSTextView can + // take focus without @FocusState stealing it back. + isPromptFocused = false onTerminalFocus() + } else { + isPromptFocused = true } } - HeaderIconButton( - symbol: "xmark", - label: "Return Miku to desktop", - tint: AgentWorkspacePalette.muted, - action: onClose - ) } .padding(.leading, 18) - .padding(.trailing, 58) + .padding(.trailing, 18 + AgentWorkspaceLayout.bubbleTailInset) .frame(height: AgentWorkspaceLayout.headerHeight) - .background(AgentWorkspacePalette.header) + .background(AgentWorkspacePalette.headerGradient) .overlay(alignment: .bottom) { Rectangle().fill(AgentWorkspacePalette.hairline).frame(height: 1) } } - private var animatedRim: some View { - LinearGradient( - colors: [ - .clear, - AgentWorkspacePalette.cyan.opacity(isRimBreathing ? 0.72 : 0.30), - .clear - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(height: 1) - .padding(.trailing, 42) - .allowsHitTesting(false) - } - private var conversation: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { if workspace.submittedPrompts.isEmpty, workspace.pendingPrompt == nil { - WorkspaceWelcome() - .padding(.top, 42) + VStack(spacing: 18) { + WorkspaceWelcome() + Button(action: onNewThread) { + Label("New thread", systemImage: "plus") + .font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 9) + } + .buttonStyle(.plain) + .foregroundStyle(AgentWorkspacePalette.cyan) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(AgentWorkspacePalette.cyan.opacity(0.12)) + ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(AgentWorkspacePalette.cyan.opacity(0.30), lineWidth: 1) + } + .accessibilityLabel("Start a new thread") + } + .frame(maxWidth: .infinity) + .padding(.top, 96) } else { - ForEach(workspace.submittedPrompts) { prompt in - AgentPromptCard(prompt: prompt, status: "sent") + ForEach(Array(workspace.submittedPrompts.enumerated()), id: \.element.id) { index, prompt in + let isCurrentRun = index == workspace.submittedPrompts.count - 1 + && workspace.runLifecycle == .running + AgentPromptCard(prompt: prompt, status: isCurrentRun ? "running" : "sent") } if let prompt = workspace.pendingPrompt { AgentPromptCard(prompt: prompt, status: "sending") @@ -121,137 +165,276 @@ struct AgentWorkspaceView: View { .foregroundStyle(AgentWorkspacePalette.error) } } - .frame(maxWidth: 680, alignment: .leading) + .frame(maxWidth: AgentWorkspaceLayout.contentColumnWidth, alignment: .leading) .padding(.horizontal, 28) .padding(.bottom, 26) - .frame(maxWidth: .infinity, alignment: .topLeading) + // Center the conversation column so it lines up with the composer. + .frame(maxWidth: .infinity, alignment: .top) } .frame(maxHeight: .infinity) } - private func composer(compact: Bool) -> some View { - VStack(alignment: .leading, spacing: 10) { - if compact { - selectorRow - promptRow - } else { - HStack(alignment: .bottom, spacing: 12) { - selectorRow - .frame(width: 182, alignment: .leading) - promptRow - } - } - } - .padding(14) - .background(AgentWorkspacePalette.composer) - .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke(AgentWorkspacePalette.composerRim, lineWidth: 1) - } - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.horizontal, 18) - .padding(.vertical, 14) - .background(AgentWorkspacePalette.header) - .overlay(alignment: .top) { - Rectangle().fill(AgentWorkspacePalette.hairline).frame(height: 1) + /// Codex-Desktop-style composer: one elevated bright input card holding the + /// multi-line prompt on top and, on a single row beneath it, the provider + /// and approval selectors on the left with a circular send on the right. + private var composer: some View { + VStack(alignment: .leading, spacing: 8) { + contextBar + composerCard } + .frame(maxWidth: AgentWorkspaceLayout.contentColumnWidth) + // Center the card on the same axis as the conversation column. + .frame(maxWidth: .infinity) + .padding(.leading, 18) + .padding(.trailing, 18 + AgentWorkspaceLayout.bubbleTailInset) + .padding(.top, 6) + .padding(.bottom, 16) .disabled(!workspace.isComposerInteractionEnabled) + .animation(.easeOut(duration: 0.15), value: isPromptFocused) + .task(id: entry.workingDirectory) { + gitBranch = Self.gitBranch(at: entry.workingDirectory) + } } - private var selectorRow: some View { - HStack(spacing: 8) { - Menu { - ForEach(AgentProvider.allCases) { option in - Button(option.title) { workspace.provider = option } + /// Codex-Desktop-style context row above the input: the thread's folder + /// (click to change), the local environment badge, and the git branch. + private var contextBar: some View { + HStack(spacing: 10) { + Button(action: onChangeFolder) { + HStack(spacing: 5) { + Image(systemName: "folder") + .font(.system(size: 10, weight: .semibold)) + Text(entry.abbreviatedWorkingDirectory ?? "Choose folder…") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .lineLimit(1) + .truncationMode(.head) + Image(systemName: "chevron.down") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(AgentWorkspacePalette.subtle) } - } label: { - AgentChoicePill( - title: workspace.provider.title, - tint: workspace.provider.tint, - symbol: workspace.provider.symbol + .foregroundStyle(AgentWorkspacePalette.muted) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.white.opacity(0.06)) ) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + } } - .menuStyle(.borderlessButton) - - Menu { - ForEach(AgentApproval.allCases) { option in - Button(option.title) { workspace.approval = option } + .buttonStyle(.plain) + .disabled(workspace.runLifecycle == .running) + .help("Change this thread's working folder") + .accessibilityLabel("Change working folder") + + HStack(spacing: 5) { + Circle() + .fill(AgentWorkspacePalette.live) + .frame(width: 5, height: 5) + Text("Local") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(AgentWorkspacePalette.subtle) + + if let gitBranch { + HStack(spacing: 5) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9, weight: .semibold)) + Text(gitBranch) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .lineLimit(1) } - } label: { - AgentChoicePill( - title: workspace.approval.title, - tint: workspace.approval.tint, - symbol: workspace.approval.symbol - ) + .foregroundStyle(AgentWorkspacePalette.subtle) } - .menuStyle(.borderlessButton) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Reads the current git branch from `.git/HEAD` without spawning a process. + /// Handles worktree-style `.git` files (`gitdir: `); detached HEADs + /// show the short commit hash. + static func gitBranch(at directory: URL?) -> String? { + guard let directory else { return nil } + var gitDir = directory.appendingPathComponent(".git") + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: gitDir.path, isDirectory: &isDirectory) else { + return nil + } + if !isDirectory.boolValue { + guard let contents = try? String(contentsOf: gitDir, encoding: .utf8), + let path = contents.split(separator: "\n").first? + .replacingOccurrences(of: "gitdir:", with: "") + .trimmingCharacters(in: .whitespaces) else { return nil } + gitDir = URL(fileURLWithPath: path, relativeTo: directory).standardizedFileURL + } + guard let head = try? String(contentsOf: gitDir.appendingPathComponent("HEAD"), encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + if head.hasPrefix("ref: refs/heads/") { + return String(head.dropFirst("ref: refs/heads/".count)) } + return String(head.prefix(7)) } - private var promptRow: some View { - HStack(alignment: .bottom, spacing: 10) { + private var composerCard: some View { + VStack(alignment: .leading, spacing: 0) { TextField("Describe what to build…", text: $workspace.prompt, axis: .vertical) .textFieldStyle(.plain) - .font(.system(size: 14, weight: .regular)) + .font(.system(size: 14.5, weight: .regular)) .foregroundStyle(.white) .focused($isPromptFocused) - .lineLimit(1...5) + .lineLimit(3...10) .onSubmit(submit) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(AgentWorkspacePalette.input) - .clipShape(RoundedRectangle(cornerRadius: 11)) - - Button(action: submit) { - HStack(spacing: 6) { - Text("Send") - Image(systemName: "arrow.up") + .frame(minHeight: 64, alignment: .topLeading) + .padding(.horizontal, 16) + .padding(.top, 14) + + HStack(spacing: 8) { + selectorMenu( + options: AgentProvider.allCases, + selection: workspace.provider, + label: "Agent", + select: { workspace.provider = $0 } + ) { option in + AgentChoicePill(title: option.title, tint: option.tint) { + ProviderMark(provider: option, size: 13) + } + } + selectorMenu( + options: AgentApproval.allCases, + selection: workspace.approval, + label: "Approval", + select: { workspace.approval = $0 } + ) { option in + AgentChoicePill(title: option.title, tint: option.tint) { + Image(systemName: option.symbol) + .font(.system(size: 10, weight: .bold)) + } + } + Spacer(minLength: 8) + WorkspaceSendButton(enabled: canSubmit, action: submit) + } + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 10) + } + .background(AgentWorkspacePalette.inputCardGradient) + .clipShape(RoundedRectangle(cornerRadius: AgentWorkspacePalette.surfaceRadius)) + .overlay { + RoundedRectangle(cornerRadius: AgentWorkspacePalette.surfaceRadius) + .stroke( + isPromptFocused + ? AgentWorkspacePalette.cyan.opacity(0.35) + : AgentWorkspacePalette.inputCardRim, + lineWidth: 1 + ) + } + .shadow(color: .black.opacity(0.22), radius: 8, x: 0, y: 4) + } + + @State private var gitBranch: String? + + private func selectorMenu( + options: [Option], + selection: Option, + label: String, + select: @escaping (Option) -> Void, + chip: (Option) -> Chip + ) -> some View where Option: RawRepresentable, Option.RawValue == String { + Menu { + ForEach(options) { option in + Button { + select(option) + } label: { + if option == selection { + Label(title(for: option), systemImage: "checkmark") + } else { + Text(title(for: option)) + } } - .font(.system(size: 12, weight: .bold)) - .frame(minWidth: 74, minHeight: 40) } - .buttonStyle(WorkspaceSendButtonStyle(enabled: canSubmit)) - .disabled(!canSubmit) - .accessibilityLabel("Start coding session") + } label: { + chip(selection) } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel(label) + } + + private func title(for option: some Any) -> String { + if let provider = option as? AgentProvider { return provider.title } + if let approval = option as? AgentApproval { return approval.title } + return "" } private func embeddedTerminal(height: CGFloat) -> some View { - TerminalPanel( - session: terminalSession, - focusRequestID: terminalFocusRequestID, - onFocus: onTerminalFocus, - onClose: {}, - onResize: { _, _ in }, - onResizeEnded: {}, - rendererPolicy: rendererPolicy, - showsChrome: false, - showsResizeHandles: false - ) - .frame(height: height) - .background(AgentWorkspacePalette.terminal) - .overlay(alignment: .top) { Rectangle().fill(AgentWorkspacePalette.hairline).frame(height: 1) } + VStack(spacing: 0) { + // Clear separator so the terminal reads as its own panel, like an + // IDE's bottom terminal divider. + HStack(spacing: 8) { + Text("TERMINAL") + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .tracking(1.2) + .foregroundStyle(AgentWorkspacePalette.subtle) + Rectangle() + .fill(Color.white.opacity(0.10)) + .frame(height: 1) + } + .padding(.leading, 14) + .padding(.trailing, 14 + AgentWorkspaceLayout.bubbleTailInset) + .frame(height: 24) + .background(AgentWorkspacePalette.headerGradient) + TerminalPanel( + session: terminalSession, + focusRequestID: terminalFocusRequestID, + onFocus: onTerminalFocus, + onClose: {}, + onResize: { _, _ in }, + onResizeEnded: {}, + rendererPolicy: rendererPolicy, + showsChrome: false, + showsResizeHandles: false + ) + .frame(height: height) + .padding(.trailing, AgentWorkspaceLayout.bubbleTailInset) + .background(AgentWorkspacePalette.terminal) + } .transition(.move(edge: .bottom).combined(with: .opacity)) - .onTapGesture(perform: onTerminalFocus) } private var canSubmit: Bool { terminalSession.isRunning && workspace.provider.supportsApproval - && !workspace.isAwaitingPromptDelivery + && workspace.isRunGateOpen && !workspace.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func submit() { guard canSubmit else { return } workspace.submit(to: terminalSession) + // Hand first responder to the terminal so the running agent owns keyboard + // input; @FocusState must release or it can reclaim the NSTextView. + isPromptFocused = false onTerminalFocus() } } private enum AgentWorkspaceLayout { static let headerHeight: CGFloat = 52 - static let compactComposerWidth: CGFloat = 620 + // Sessions sidebar column on the bubble's leading edge; the native traffic + // lights overlay its top, so its content starts below them. + static let sidebarWidth: CGFloat = 212 + static let sidebarTopInset: CGFloat = 48 + // Conversation and composer share one centered column. + static let contentColumnWidth: CGFloat = 720 + // The speech-bubble tail occupies the trailing edge of the workspace frame + // (SpeechBubbleShape insets the bubble body by up to 40pt); content must not + // lay out into that clipped zone. + static let bubbleTailInset: CGFloat = 40 static func terminalHeight(for workspaceHeight: CGFloat) -> CGFloat { min(280, max(116, workspaceHeight * 0.34)) @@ -260,16 +443,297 @@ private enum AgentWorkspaceLayout { private struct WorkspaceWelcome: View { var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(spacing: 16) { MikuCodeLogo(compact: false) - Text("A local workspace for focused coding sessions.") - .font(.system(size: 14, weight: .regular)) + VStack(spacing: 8) { + Text("A local workspace for focused coding sessions.") + .font(.system(size: 15, weight: .medium)) + .tracking(0.1) + .foregroundStyle(AgentWorkspacePalette.muted) + Text("Choose an agent, set its approval mode, then describe the work.") + .font(.system(size: 12, weight: .medium)) + .tracking(0.15) + .foregroundStyle(AgentWorkspacePalette.subtle) + } + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, alignment: .center) + } +} + +/// Centered empty state shown when no thread is selected: pick a working +/// folder (or keep the last one) and start a new thread — codex-desktop style. +@MainActor +private struct NewThreadPane: View { + @ObservedObject var store: WorkspaceSessionStore + + var body: some View { + VStack(spacing: 22) { + MikuCodeLogo(compact: false) + Text("Start a new thread") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(AgentWorkspacePalette.muted) + + Button(action: chooseFolder) { + HStack(spacing: 8) { + Image(systemName: "folder") + .font(.system(size: 11, weight: .semibold)) + Text(folderTitle) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .lineLimit(1) + .truncationMode(.head) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(AgentWorkspacePalette.subtle) + } .foregroundStyle(AgentWorkspacePalette.muted) - Text("Choose an agent, set its approval mode, then describe the work.") - .font(.system(size: 12, weight: .regular)) + .padding(.horizontal, 13) + .frame(height: 32) + .background(AgentWorkspacePalette.control) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("Choose working folder") + + Button(action: createThread) { + HStack(spacing: 8) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .bold)) + Text("New thread") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(AgentWorkspacePalette.sendText) + .padding(.horizontal, 20) + .frame(height: 38) + .background(AgentWorkspacePalette.sendGradient) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("New thread") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.trailing, AgentWorkspaceLayout.bubbleTailInset) + } + + private var folderTitle: String { + guard let directory = store.defaultWorkingDirectory else { return "Choose folder…" } + return (directory.path as NSString).abbreviatingWithTildeInPath + } + + private func chooseFolder() { + guard let picked = WorkspaceFolderPicker.choose() else { return } + store.setDefaultWorkingDirectory(picked) + } + + private func createThread() { + if store.defaultWorkingDirectory == nil, + let picked = WorkspaceFolderPicker.choose() { + store.setDefaultWorkingDirectory(picked) + } + store.createSession() + } +} + +private struct SessionSidebar: View { + @ObservedObject var store: WorkspaceSessionStore + let onCreate: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("SESSIONS") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .tracking(1.1) + .foregroundStyle(AgentWorkspacePalette.subtle) + Spacer() + HeaderIconButton( + symbol: "plus", + label: "New session", + tint: AgentWorkspacePalette.muted, + action: onCreate + ) + } + .padding(.leading, 16) + .padding(.trailing, 10) + .padding(.top, AgentWorkspaceLayout.sidebarTopInset) + .padding(.bottom, 6) + + ScrollView { + VStack(spacing: 2) { + ForEach(store.sessions) { entry in + SessionRow( + agent: entry.agent, + entry: entry, + isSelected: entry.id == store.selectedID, + select: { store.select(entry.id) }, + rename: { store.renameSession(entry.id, to: $0) }, + delete: { store.deleteSession(entry.id) } + ) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 12) + } + } + .frame(width: AgentWorkspaceLayout.sidebarWidth) + .frame(maxHeight: .infinity) + .background(AgentWorkspacePalette.sidebarGradient) + .overlay(alignment: .trailing) { + Rectangle() + .fill(AgentWorkspacePalette.hairline) + .frame(width: 1) + .allowsHitTesting(false) + } + .accessibilityLabel("Sessions") + } +} + +private struct SessionRow: View { + @ObservedObject var agent: AgentWorkspaceModel + @ObservedObject var entry: WorkspaceSessionEntry + let isSelected: Bool + let select: () -> Void + let rename: (String) -> Void + let delete: () -> Void + @State private var isHovered = false + @State private var isRenaming = false + @State private var draftTitle = "" + @State private var isConfirmingDelete = false + @FocusState private var isRenameFieldFocused: Bool + + var body: some View { + Button(action: select) { + VStack(alignment: .leading, spacing: 3) { + if isRenaming { + TextField("Thread name", text: $draftTitle) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .focused($isRenameFieldFocused) + .onSubmit(commitRename) + .onExitCommand { isRenaming = false } + .onChange(of: isRenameFieldFocused) { _, focused in + if !focused, isRenaming { commitRename() } + } + } else { + Text(entry.title) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? .white : AgentWorkspacePalette.muted) + .lineLimit(1) + } + HStack(spacing: 5) { + Text(entry.createdAt.formatted(date: .omitted, time: .shortened)) + if let folder = entry.abbreviatedWorkingDirectory { + Text(folder) + .lineLimit(1) + .truncationMode(.head) + } + } + .font(.system(size: 10, weight: .medium)) .foregroundStyle(AgentWorkspacePalette.subtle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) } - .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .background( + isSelected + ? AgentWorkspacePalette.controlHover + : (isHovered ? AgentWorkspacePalette.control : .clear) + ) + .clipShape(RoundedRectangle(cornerRadius: AgentWorkspacePalette.controlRadius)) + .onHover { isHovered = $0 } + .animation(.easeOut(duration: 0.12), value: isHovered) + .contextMenu { + Button("Rename…") { beginRename() } + Divider() + Button("Delete", role: .destructive) { isConfirmingDelete = true } + } + .confirmationDialog( + "Delete “\(entry.title)”?", + isPresented: $isConfirmingDelete, + titleVisibility: .visible + ) { + Button("Delete Thread", role: .destructive, action: delete) + Button("Cancel", role: .cancel) {} + } message: { + Text("The thread's transcript and terminal history will be removed.") + } + .accessibilityLabel("Session \(entry.title)") + } + + private func beginRename() { + draftTitle = entry.title + isRenaming = true + isRenameFieldFocused = true + } + + private func commitRename() { + isRenaming = false + rename(draftTitle) + } +} + +/// Vector provider marks approximating the agents' official logos: Claude is a +/// coral radial starburst, Codex a monochrome pinwheel knot. Drawn in SwiftUI so +/// no bundled bitmap is needed. +struct ProviderMark: View { + let provider: AgentProvider + var size: CGFloat = 14 + + var body: some View { + switch provider { + case .claude: + RadialBurstMark(rays: 9, size: size) + .foregroundStyle(provider.tint) + case .codex: + PinwheelKnotMark(size: size) + .foregroundStyle(provider.tint) + case .pi, .openCode: + Image(systemName: provider.symbol) + .font(.system(size: size * 0.72, weight: .bold)) + .foregroundStyle(provider.tint) + } + } +} + +struct RadialBurstMark: View { + let rays: Int + let size: CGFloat + + var body: some View { + ZStack { + ForEach(0 ..< rays, id: \.self) { index in + Capsule() + .frame(width: size * 0.14, height: size * 0.46) + .offset(y: -size * 0.26) + .rotationEffect(.degrees(Double(index) / Double(rays) * 360)) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +struct PinwheelKnotMark: View { + let size: CGFloat + + var body: some View { + ZStack { + ForEach(0 ..< 6, id: \.self) { index in + RoundedRectangle(cornerRadius: size * 0.09) + .frame(width: size * 0.16, height: size * 0.56) + .offset(x: size * 0.10, y: -size * 0.18) + .rotationEffect(.degrees(Double(index) * 60)) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) } } @@ -287,15 +751,15 @@ private struct MikuCodeLogo: View { } .frame(width: compact ? 26 : 34, height: compact ? 26 : 34) - VStack(alignment: .leading, spacing: compact ? 0 : 2) { + VStack(alignment: .leading, spacing: compact ? 0 : 3) { Text("MIKU CODE") .font(.system(size: compact ? 11 : 17, weight: .bold, design: .default)) .tracking(compact ? 1.2 : 1.8) - .foregroundStyle(.white) + .foregroundStyle(.white.opacity(0.96)) if !compact { Text("CODING AGENT DESKTOP") .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .tracking(0.7) + .tracking(1.1) .foregroundStyle(AgentWorkspacePalette.subtle) } } @@ -315,7 +779,7 @@ private struct WorkspaceStatus: View { .frame(width: 6, height: 6) Text(isRunning ? "LOCAL SHELL READY" : "STARTING") .font(.system(size: 9, weight: .bold, design: .monospaced)) - .tracking(0.4) + .tracking(0.6) .foregroundStyle(AgentWorkspacePalette.subtle) } .accessibilityLabel(isRunning ? "Local shell ready" : "Local shell starting") @@ -327,6 +791,7 @@ private struct HeaderIconButton: View { let label: String let tint: Color let action: () -> Void + @State private var isHovered = false var body: some View { Button(action: action) { @@ -334,14 +799,28 @@ private struct HeaderIconButton: View { .font(.system(size: 12, weight: .bold)) .foregroundStyle(tint) .frame(width: 30, height: 30) - .background(AgentWorkspacePalette.control) - .clipShape(Circle()) } - .buttonStyle(.plain) + .buttonStyle(HeaderIconButtonStyle(isHovered: isHovered)) + .onHover { isHovered = $0 } .accessibilityLabel(label) } } +private struct HeaderIconButtonStyle: ButtonStyle { + let isHovered: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + (isHovered || configuration.isPressed) ? AgentWorkspacePalette.controlHover : AgentWorkspacePalette.control + ) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + .animation(.easeOut(duration: 0.14), value: isHovered) + } +} + private struct AgentPromptCard: View { let prompt: AgentPrompt let status: String @@ -365,7 +844,7 @@ private struct AgentPromptCard: View { .overlay(alignment: .leading) { Rectangle().fill(prompt.provider.tint).frame(width: 2) } - .clipShape(RoundedRectangle(cornerRadius: 14)) + .clipShape(RoundedRectangle(cornerRadius: AgentWorkspacePalette.cardRadius)) } } @@ -380,65 +859,126 @@ private struct AgentTag: View { .padding(.horizontal, 7) .padding(.vertical, 4) .background(tint.opacity(0.14)) - .clipShape(Capsule()) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) } } -private struct AgentChoicePill: View { +private struct AgentChoicePill: View { let title: String let tint: Color - let symbol: String + @ViewBuilder let icon: () -> Icon + @State private var isHovered = false var body: some View { HStack(spacing: 6) { - Image(systemName: symbol) - .font(.system(size: 9, weight: .bold)) + icon() Text(title) Image(systemName: "chevron.down") .font(.system(size: 8, weight: .bold)) .foregroundStyle(AgentWorkspacePalette.subtle) } - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(tint) - .padding(.horizontal, 10) + .padding(.horizontal, 11) .frame(height: 30) - .background(tint.opacity(0.14)) - .overlay { Capsule().stroke(tint.opacity(0.28), lineWidth: 1) } - .clipShape(Capsule()) + .background(Color.white.opacity(isHovered ? 0.13 : 0.08)) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(isHovered ? 0.22 : 0.10), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .animation(.easeOut(duration: 0.14), value: isHovered) + .onHover { isHovered = $0 } + } +} + +/// Codex-Desktop-style primary action: a circular teal arrow-up living inside +/// the composer card, with hover/pressed feedback and a dimmed-but-visible +/// disabled state. +private struct WorkspaceSendButton: View { + let enabled: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: "arrow.up") + .font(.system(size: 14, weight: .bold)) + .frame(width: 34, height: 34) + } + .buttonStyle(WorkspaceSendButtonStyle(enabled: enabled, isHovered: isHovered)) + .disabled(!enabled) + .onHover { isHovered = enabled && $0 } + .accessibilityLabel("Start coding session") } } private struct WorkspaceSendButtonStyle: ButtonStyle { let enabled: Bool + let isHovered: Bool func makeBody(configuration: Configuration) -> some View { configuration.label - .foregroundStyle(enabled ? AgentWorkspacePalette.sendText : AgentWorkspacePalette.subtle) - .background(enabled ? AgentWorkspacePalette.send : AgentWorkspacePalette.control) - .clipShape(RoundedRectangle(cornerRadius: 11)) - .opacity(configuration.isPressed ? 0.72 : 1) - .scaleEffect(configuration.isPressed ? 0.97 : 1) + .foregroundStyle(enabled ? AgentWorkspacePalette.sendText : AgentWorkspacePalette.sendDisabledText) + .background { + if enabled { + AgentWorkspacePalette.sendGradient + } else { + AgentWorkspacePalette.control + } + } + .clipShape(Circle()) + .overlay { + Circle().stroke( + enabled ? Color.white.opacity(isHovered ? 0.28 : 0.14) : AgentWorkspacePalette.hairline, + lineWidth: 1 + ) + } + .opacity(enabled ? (configuration.isPressed ? 0.82 : 1) : 0.55) + .scaleEffect(configuration.isPressed ? 0.94 : (isHovered ? 1.04 : 1)) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + .animation(.easeOut(duration: 0.16), value: isHovered) } } private enum AgentWorkspacePalette { - static let surface = Color(red: 0.025, green: 0.055, blue: 0.095) - static let header = Color(red: 0.035, green: 0.082, blue: 0.136) - static let composer = Color(red: 0.050, green: 0.113, blue: 0.181) - static let input = Color(red: 0.020, green: 0.052, blue: 0.090) - static let terminal = Color(red: 0.014, green: 0.032, blue: 0.055) - static let message = Color(red: 0.080, green: 0.170, blue: 0.255).opacity(0.20) - static let control = Color.white.opacity(0.075) - static let rim = Color(red: 0.350, green: 0.730, blue: 0.970).opacity(0.34) - static let hairline = Color(red: 0.420, green: 0.750, blue: 0.960).opacity(0.16) - static let composerRim = Color(red: 0.410, green: 0.740, blue: 0.970).opacity(0.30) - static let muted = Color(red: 0.780, green: 0.880, blue: 0.970).opacity(0.80) - static let subtle = Color(red: 0.620, green: 0.750, blue: 0.860).opacity(0.72) + // Compact radius family: flat, professional shadcn-dark proportions. + static let controlRadius: CGFloat = 6 + static let cardRadius: CGFloat = 8 + static let surfaceRadius: CGFloat = 10 + + // Flat surfaces in one dark-navy family. The historical *Gradient names are + // kept so call sites stay stable; each is now a single solid color. + static let surfaceGradient = Color(red: 0.039, green: 0.067, blue: 0.110) + static let headerGradient = Color(red: 0.055, green: 0.090, blue: 0.135) + static let composerGradient = Color(red: 0.055, green: 0.090, blue: 0.135) + // The composer input card stays the brightest surface in the workspace so + // the prompt area reads as the primary focus. + static let inputCardGradient = Color(red: 0.118, green: 0.165, blue: 0.224) + static let inputCardRim = Color.white.opacity(0.10) + static let sidebarGradient = Color(red: 0.028, green: 0.048, blue: 0.078) + + static let input = Color(red: 0.018, green: 0.046, blue: 0.080) + static let terminal = Color(red: 0.012, green: 0.028, blue: 0.050) + static let message = Color(red: 0.084, green: 0.174, blue: 0.258).opacity(0.20) + static let control = Color.white.opacity(0.07) + static let controlHover = Color.white.opacity(0.12) + + // Single subtle bubble outline; no gradient rim. + static let rimGradient = Color.white.opacity(0.12) + + // Crisp 1px separations share one divider tone; strokes one border tone. + static let hairline = Color.white.opacity(0.08) + static let composerRim = Color.white.opacity(0.10) + static let muted = Color(red: 0.800, green: 0.890, blue: 0.970).opacity(0.86) + static let subtle = Color(red: 0.620, green: 0.750, blue: 0.860).opacity(0.68) static let cyan = Color(red: 0.360, green: 0.820, blue: 1.000) static let live = Color(red: 0.390, green: 0.920, blue: 0.740) - static let send = Color(red: 0.390, green: 0.820, blue: 1.000) - static let sendText = Color(red: 0.010, green: 0.055, blue: 0.095) + + static let sendGradient = Color(red: 0.320, green: 0.760, blue: 0.950) + static let sendText = Color(red: 0.008, green: 0.048, blue: 0.086) + static let sendDisabledText = Color.white.opacity(0.38) static let logoBackground = Color(red: 0.070, green: 0.180, blue: 0.270) static let error = Color(red: 1.000, green: 0.500, blue: 0.500) } diff --git a/Sources/MikuCodeApp/Application/MikuCodeApp.swift b/Sources/MikuCodeApp/Application/MikuCodeApp.swift index b915a05..2c31833 100644 --- a/Sources/MikuCodeApp/Application/MikuCodeApp.swift +++ b/Sources/MikuCodeApp/Application/MikuCodeApp.swift @@ -71,76 +71,23 @@ final class MikuApplicationDelegate: NSObject, NSApplicationDelegate { @MainActor struct MikuRootView: View { @ObservedObject var presentation: AppPresentationStore - @ObservedObject var terminalSession: TerminalSessionModel - @ObservedObject var agentWorkspace: AgentWorkspaceModel @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var resizedTerminalFrame: CGRect? - @State private var resizeOrigin: CGRect? - @State private var isActiveMikuHovering = false let onOpen: () -> Void - let onOpeningComplete: () -> Void - let onClose: () -> Void - let onClosingComplete: () -> Void - var terminalFrameOverride: CGRect? = nil - var terminalRendererPolicy: TerminalRendererPolicy = .automatic - - init( - presentation: AppPresentationStore, - terminalSession: TerminalSessionModel, - agentWorkspace: AgentWorkspaceModel = AgentWorkspaceModel(), - onOpen: @escaping () -> Void, - onOpeningComplete: @escaping () -> Void, - onClose: @escaping () -> Void, - onClosingComplete: @escaping () -> Void, - terminalFrameOverride: CGRect? = nil, - terminalRendererPolicy: TerminalRendererPolicy = .automatic - ) { - self.presentation = presentation - self.terminalSession = terminalSession - self.agentWorkspace = agentWorkspace - self.onOpen = onOpen - self.onOpeningComplete = onOpeningComplete - self.onClose = onClose - self.onClosingComplete = onClosingComplete - self.terminalFrameOverride = terminalFrameOverride - self.terminalRendererPolicy = terminalRendererPolicy - } var body: some View { GeometryReader { geometry in let metrics = PresentationLayout.metrics(in: geometry.size) - let terminalFrame = resizedTerminalFrame ?? terminalFrameOverride ?? metrics.terminalFrame ZStack(alignment: .topLeading) { if presentation.state == .idle { idleEntry(frame: metrics.idleEntryFrame) .transition(reduceMotion ? .identity : .opacity) - } else { - activeSurface( - terminalFrame: terminalFrame, - displaySize: geometry.size - ) - .opacity(presentation.state == .closing ? 0 : 1) - .scaleEffect( - presentation.state == .closing ? 0.985 : 1, - anchor: .bottomTrailing - ) - .transition( - reduceMotion - ? .identity - : .opacity.combined( - with: .scale(scale: 0.965, anchor: .bottomTrailing) - ) - ) } } .frame(width: geometry.size.width, height: geometry.size.height) .animation(stateAnimation, value: presentation.state) } .background(Color.clear) - .task(id: presentation.state) { - await finishTransientState(presentation.state) - } } private func idleEntry(frame: CGRect) -> some View { @@ -149,85 +96,8 @@ struct MikuRootView: View { .position(x: frame.midX, y: frame.midY) } - private func activeSurface(terminalFrame: CGRect, displaySize: CGSize) -> some View { - let mikuFrame = PresentationLayout.mikuFrame(for: terminalFrame, in: displaySize) - return ZStack(alignment: .topLeading) { - MikuVisualTokens.activeVeil - .accessibilityHidden(true) - 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) - } - .buttonStyle(.plain) - .frame(width: mikuFrame.width, height: mikuFrame.height) - .position(x: mikuFrame.midX, y: mikuFrame.midY) - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(reduceMotion ? nil : .easeOut(duration: MikuVisualTokens.pressDuration)) { - isActiveMikuHovering = hovering - } - } - .accessibilityLabel("Return Miku to desktop") - .accessibilityHint("Click the focused Miku to close the terminal") - } - .accessibilityElement(children: .contain) - .accessibilityLabel("MikuCode terminal") - } - - private func resizeTerminal( - edge: TerminalBubbleResizeEdge, - translation: CGSize, - displaySize: CGSize - ) { - let origin = resizeOrigin ?? resizedTerminalFrame ?? PresentationLayout.metrics(in: displaySize).terminalFrame - if resizeOrigin == nil { - resizeOrigin = origin - } - resizedTerminalFrame = TerminalBubbleLayout.resized( - origin, - by: translation, - edge: edge, - within: TerminalBubbleLayout.resizeBounds(in: displaySize) - ) - } - private var stateAnimation: Animation? { - guard !reduceMotion else { return nil } - return presentation.state == .closing - ? .easeIn(duration: MikuVisualTokens.closingDuration) - : .easeOut(duration: MikuVisualTokens.openingDuration) - } - - private func finishTransientState(_ state: PresentationState) async { - switch state { - case .opening: - if !reduceMotion { - try? await Task.sleep(for: .seconds(MikuVisualTokens.openingDuration)) - } - guard !Task.isCancelled else { return } - onOpeningComplete() - case .closing: - if !reduceMotion { - try? await Task.sleep(for: .seconds(MikuVisualTokens.closingDuration)) - } - guard !Task.isCancelled else { return } - onClosingComplete() - case .idle, .terminal: - break - } + reduceMotion ? nil : .easeOut(duration: MikuVisualTokens.openingDuration) } } diff --git a/Sources/MikuCodeApp/Companion/AppPresentation.swift b/Sources/MikuCodeApp/Companion/AppPresentation.swift index a52a333..b32995c 100644 --- a/Sources/MikuCodeApp/Companion/AppPresentation.swift +++ b/Sources/MikuCodeApp/Companion/AppPresentation.swift @@ -262,6 +262,27 @@ enum PresentationLayout { ) } + /// Layout for the standard workspace window: the bubble is anchored flush + /// to the window's top-left (so the native traffic lights sit at the + /// bubble's top-left corner) and the Miku rail fills the remaining + /// trailing region over a transparent background. + static func workspaceMetrics(in size: CGSize) -> PresentationLayoutMetrics { + let width = max(1, size.width) + let height = max(1, size.height) + let mikuRail = max(200, width * 0.22) + let terminalFrame = CGRect( + x: 0, + y: 0, + width: max(1, width - mikuRail), + height: height + ) + return PresentationLayoutMetrics( + idleEntryFrame: .zero, + terminalFrame: terminalFrame, + mikuFrame: mikuFrame(for: terminalFrame, in: size) + ) + } + static func mikuFrame(for terminalFrame: CGRect, in size: CGSize) -> CGRect { let width = max(1, size.width) let height = max(1, size.height) @@ -298,7 +319,6 @@ enum MikuVisualTokens { static let terminalPaddingY: CGFloat = 12 static let terminalCellLineHeight: CGFloat = 16 - static let activeVeil = Color.black.opacity(0.22) static let bubbleTop = Color(red: 0.060, green: 0.067, blue: 0.075).opacity(0.98) static let bubbleBottom = Color(red: 0.025, green: 0.030, blue: 0.035).opacity(0.99) static let bubbleRim = Color.white.opacity(0.11) diff --git a/Sources/MikuCodeApp/Companion/MikuCompanion.swift b/Sources/MikuCodeApp/Companion/MikuCompanion.swift index e5c7995..0241415 100644 --- a/Sources/MikuCodeApp/Companion/MikuCompanion.swift +++ b/Sources/MikuCodeApp/Companion/MikuCompanion.swift @@ -115,9 +115,12 @@ struct MikuCompanion: View { .scaleEffect(looksAtUser ? 1.035 : 1, anchor: .bottom) .saturation(scale == .idle ? 0.98 : 1) .brightness(looksAtUser ? 0.025 : 0) + // Idle keeps a soft teal glow; the active rail sits on a fully + // transparent window region where a wide low-alpha glow quantizes + // into visible concentric bands, so it draws with no shadow. .shadow( - color: MikuVisualTokens.mikuTeal.opacity(scale == .idle ? 0.14 : 0.10), - radius: scale == .idle ? 12 : 18 + color: MikuVisualTokens.mikuTeal.opacity(scale == .idle ? 0.14 : 0), + radius: scale == .idle ? 12 : 0 ) .animation( .easeInOut(duration: 0.18), diff --git a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift index ee123c2..424df9f 100644 --- a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift +++ b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift @@ -5,9 +5,9 @@ import SwiftUI @MainActor final class MikuPanelCoordinator: NSObject, NSWindowDelegate { let panel: MikuPanel + private(set) var workspaceWindow: WorkspaceWindow? - private let terminalSession: TerminalSessionModel - private let agentWorkspace = AgentWorkspaceModel() + private let sessionStore: WorkspaceSessionStore private let presentation: AppPresentationStore private let hitTestView: OverlayHitTestView private let ordersFront: Bool @@ -21,10 +21,12 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { ordersFront: Bool = true ) { self.screenFrame = screenFrame - self.terminalSession = terminalSession + // nil in production: the store restores persisted threads (or starts + // empty, showing the new-thread pane). Injected terminals are for tests. + let initialTerminal = terminalSession ?? (terminalWorkspace?.focusedSession as? TerminalSessionModel) - ?? TerminalSessionModel() - presentation = AppPresentationStore(terminalLifecycle: self.terminalSession) + sessionStore = WorkspaceSessionStore(initialTerminal: initialTerminal) + presentation = AppPresentationStore(terminalLifecycle: sessionStore) self.ordersFront = ordersFront panel = MikuPanel( contentRect: screenFrame, @@ -39,7 +41,7 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { configurePanelBase() installRootView() - configurePanel(for: .idle) + configurePanelIdle() if ordersFront { panel.orderFrontRegardless() } @@ -48,27 +50,43 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { func requestOpen() { guard presentation.requestOpen() else { return } refreshScreenFrameFromPanel() - configurePanel(for: .opening) + let window = ensureWorkspaceWindow() + workspacePresentedAt = ProcessInfo.processInfo.systemUptime + panel.orderOut(nil) guard ordersFront else { return } NSApplication.shared.activate(ignoringOtherApps: true) - panel.makeKeyAndOrderFront(nil) + window.makeKeyAndOrderFront(nil) + } + + /// Close requested by clicking the workspace Miku. The click that opened the + /// workspace lands on the same screen region the Miku close target appears + /// in, and stale/replayed events from that press can fire the close button + /// right after presentation. Only honor closes backed by a fresh mouse event + /// that genuinely happened inside the presented workspace window. + private var workspacePresentedAt: TimeInterval = 0 + + func requestCloseFromMikuTarget() { + guard let event = NSApplication.shared.currentEvent, + event.window === workspaceWindow, + event.timestamp > workspacePresentedAt else { return } + requestClose() } func completeOpening() { - guard presentation.finishOpening() else { return } - configurePanel(for: .terminal) - guard ordersFront else { return } - panel.makeKeyAndOrderFront(nil) + _ = presentation.finishOpening() } func requestClose() { guard presentation.requestClose() else { return } - configurePanel(for: .closing) + if let workspaceWindow, workspaceWindow.styleMask.contains(.fullScreen) { + workspaceWindow.toggleFullScreen(nil) + } } func completeClosing() { guard presentation.finishClosing() else { return } - configurePanel(for: .idle, idleLevel: .floating) + workspaceWindow?.orderOut(nil) + configurePanelIdle(level: .floating) guard ordersFront else { return } panel.orderFrontRegardless() } @@ -81,7 +99,12 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { func revealFromDock() { guard presentation.state == .idle else { - panel.makeKeyAndOrderFront(nil) + if let workspaceWindow { + if workspaceWindow.isMiniaturized { + workspaceWindow.deminiaturize(nil) + } + workspaceWindow.makeKeyAndOrderFront(nil) + } return } panel.level = .floating @@ -97,6 +120,10 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { func shutdown() { _ = presentation.requestClose() + // Termination does not run the closing animation task, so stop the + // threads directly: each terminal saves its snapshot and the store + // persists the thread index. + sessionStore.stop() } func windowShouldClose(_ sender: NSWindow) -> Bool { @@ -130,12 +157,7 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { private func installRootView() { let root = MikuRootView( presentation: presentation, - terminalSession: terminalSession, - agentWorkspace: agentWorkspace, - onOpen: { [weak self] in self?.requestOpen() }, - onOpeningComplete: { [weak self] in self?.completeOpening() }, - onClose: { [weak self] in self?.requestClose() }, - onClosingComplete: { [weak self] in self?.completeClosing() } + onOpen: { [weak self] in self?.requestOpen() } ) let hostingView = NSHostingView(rootView: root) hostingView.translatesAutoresizingMaskIntoConstraints = false @@ -150,22 +172,29 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { self.hostingView = hostingView } - private func configurePanel( - for state: PresentationState, - idleLevel: NSWindow.Level? = nil - ) { - panel.isTerminalPresented = state != .idle - hitTestView.idlePassthroughEnabled = state == .idle - - if state == .idle { - panel.styleMask = [.borderless, .nonactivatingPanel] - panel.level = idleLevel ?? Self.desktopCompanionLevel - panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] - } else { - panel.styleMask = [.borderless] - panel.level = .normal - panel.collectionBehavior = [.moveToActiveSpace] - } + private func ensureWorkspaceWindow() -> WorkspaceWindow { + if let workspaceWindow { return workspaceWindow } + let contentFrame = (panel.screen ?? NSScreen.main)?.visibleFrame ?? screenFrame + let window = WorkspaceWindow(contentRect: contentFrame) + window.delegate = self + window.onCloseRequest = { [weak self] in self?.requestClose() } + let root = WorkspaceRootView( + presentation: presentation, + sessionStore: sessionStore, + onOpeningComplete: { [weak self] in self?.completeOpening() }, + onClose: { [weak self] in self?.requestCloseFromMikuTarget() }, + onClosingComplete: { [weak self] in self?.completeClosing() } + ) + window.contentView = NSHostingView(rootView: root) + workspaceWindow = window + return window + } + + private func configurePanelIdle(level: NSWindow.Level? = nil) { + panel.styleMask = [.borderless, .nonactivatingPanel] + panel.becomesKeyOnlyIfNeeded = true + panel.level = level ?? Self.desktopCompanionLevel + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] panel.setFrame(screenFrame, display: true) updateIdleInteractiveRegion() } @@ -194,21 +223,13 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { } final class MikuPanel: NSPanel { - var isTerminalPresented = false var onCloseRequest: (() -> Void)? var onIdleEntryClick: ((NSPoint) -> Void)? - var onSplitRight: (() -> Void)? - var onSplitBottom: (() -> Void)? - var onNewTab: (() -> Void)? override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { - isTerminalPresented - } - override init( contentRect: NSRect, styleMask style: NSWindow.StyleMask, @@ -235,31 +256,9 @@ final class MikuPanel: NSPanel { override func sendEvent(_ event: NSEvent) { super.sendEvent(event) - guard event.type == .leftMouseUp, !isTerminalPresented else { return } + guard event.type == .leftMouseUp else { return } onIdleEntryClick?(event.locationInWindow) } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if isTerminalPresented, let action = TerminalWorkspaceShortcut.action(for: event) { - switch action { - case .newTab: - guard let onNewTab else { return super.performKeyEquivalent(with: event) } - onNewTab() - case .splitRight: - guard let onSplitRight else { return super.performKeyEquivalent(with: event) } - onSplitRight() - case .splitBottom: - guard let onSplitBottom else { return super.performKeyEquivalent(with: event) } - onSplitBottom() - } - return true - } - if PanelCloseKey.matches(event) { - onCloseRequest?() - return true - } - return super.performKeyEquivalent(with: event) - } } enum PanelCloseKey { @@ -288,7 +287,13 @@ final class OverlayHitTestView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { let fallbackRegion = PresentationLayout.metrics(in: bounds.size).idleEntryFrame let activeRegion = interactiveRegion.isEmpty ? fallbackRegion : interactiveRegion - if idlePassthroughEnabled, !activeRegion.contains(point) { + // `point` arrives in the superview's (unflipped, bottom-left) space, but + // interactiveRegion lives in this flipped view's space. Convert for the + // containment check only — otherwise the region is vertically mirrored + // and clicks on idle Miku pass straight through. super.hitTest still + // needs the original, unconverted point. + let localPoint = superview.map { convert(point, from: $0) } ?? point + if idlePassthroughEnabled, !activeRegion.contains(localPoint) { return nil } return super.hitTest(point) diff --git a/Sources/MikuCodeApp/Companion/WorkspaceWindow.swift b/Sources/MikuCodeApp/Companion/WorkspaceWindow.swift new file mode 100644 index 0000000..167d1b4 --- /dev/null +++ b/Sources/MikuCodeApp/Companion/WorkspaceWindow.swift @@ -0,0 +1,174 @@ +import AppKit +import SwiftUI + +/// The presented coding-agent workspace is a standard macOS window: native +/// traffic lights anchored at the bubble's top-left, native drag, resize, and +/// full screen. Only the background is transparent — outside the bubble and +/// behind the Miku rail the desktop shows through. +final class WorkspaceWindow: NSWindow { + /// Matches AgentWorkspaceLayout.headerHeight so the traffic lights center + /// vertically inside the bubble header. + static let titlebarHeight: CGFloat = 52 + + var onCloseRequest: (() -> Void)? + var onNewTab: (() -> Void)? + var onSplitRight: (() -> Void)? + var onSplitBottom: (() -> Void)? + + init(contentRect: NSRect) { + super.init( + contentRect: contentRect, + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + isReleasedWhenClosed = false + title = "MikuCode" + titleVisibility = .hidden + titlebarAppearsTransparent = true + isOpaque = false + backgroundColor = .clear + hasShadow = false + minSize = CGSize(width: 900, height: 600) + collectionBehavior = [.fullScreenPrimary] + installTallTitlebar() + } + + /// An empty full-height titlebar accessory grows the hidden titlebar to the + /// bubble-header height, which makes AppKit center the standard close, + /// minimize, and zoom buttons vertically inside the bubble header. + private func installTallTitlebar() { + let accessory = NSTitlebarAccessoryViewController() + accessory.view = NSView( + frame: CGRect(x: 0, y: 0, width: 0, height: Self.titlebarHeight) + ) + accessory.layoutAttribute = .right + addTitlebarAccessoryViewController(accessory) + } + + override func performClose(_ sender: Any?) { + onCloseRequest?() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if let action = TerminalWorkspaceShortcut.action(for: event) { + switch action { + case .newTab: + guard let onNewTab else { return super.performKeyEquivalent(with: event) } + onNewTab() + case .splitRight: + guard let onSplitRight else { return super.performKeyEquivalent(with: event) } + onSplitRight() + case .splitBottom: + guard let onSplitBottom else { return super.performKeyEquivalent(with: event) } + onSplitBottom() + } + return true + } + if PanelCloseKey.matches(event) { + onCloseRequest?() + return true + } + return super.performKeyEquivalent(with: event) + } +} + +@MainActor +struct WorkspaceRootView: View { + @ObservedObject var presentation: AppPresentationStore + @ObservedObject var sessionStore: WorkspaceSessionStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isActiveMikuHovering = false + + let onOpeningComplete: () -> Void + let onClose: () -> Void + let onClosingComplete: () -> Void + var terminalRendererPolicy: TerminalRendererPolicy = .automatic + + var body: some View { + GeometryReader { geometry in + let metrics = PresentationLayout.workspaceMetrics(in: geometry.size) + ZStack(alignment: .topLeading) { + if presentation.state != .idle { + activeSurface(metrics: metrics) + .opacity(presentation.state == .closing ? 0 : 1) + .scaleEffect( + presentation.state == .closing ? 0.985 : 1, + anchor: .bottomTrailing + ) + .transition( + reduceMotion + ? .identity + : .opacity.combined( + with: .scale(scale: 0.965, anchor: .bottomTrailing) + ) + ) + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + .animation(stateAnimation, value: presentation.state) + } + .background(Color.clear) + // The bubble owns the full window, including the (hidden) titlebar area, + // so the native traffic lights sit on the bubble rather than above it. + .ignoresSafeArea() + .task(id: presentation.state) { + await finishTransientState(presentation.state) + } + } + + private func activeSurface(metrics: PresentationLayoutMetrics) -> some View { + ZStack(alignment: .topLeading) { + AgentWorkspaceView( + sessionStore: sessionStore, + selected: sessionStore.selected, + terminalFocusRequestID: presentation.focusRequestID, + onTerminalFocus: presentation.requestTerminalFocus, + rendererPolicy: terminalRendererPolicy + ) + .frame(width: metrics.terminalFrame.width, height: metrics.terminalFrame.height) + .position(x: metrics.terminalFrame.midX, y: metrics.terminalFrame.midY) + Button(action: onClose) { + ActiveMikuCloseTarget(isHovering: isActiveMikuHovering) + } + .buttonStyle(.plain) + .frame(width: metrics.mikuFrame.width, height: metrics.mikuFrame.height) + .position(x: metrics.mikuFrame.midX, y: metrics.mikuFrame.midY) + .onHover { hovering in + withAnimation(reduceMotion ? nil : .easeOut(duration: MikuVisualTokens.pressDuration)) { + isActiveMikuHovering = hovering + } + } + .accessibilityLabel("Return Miku to desktop") + .accessibilityHint("Click the focused Miku to close the terminal") + } + .accessibilityElement(children: .contain) + .accessibilityLabel("MikuCode terminal") + } + + private var stateAnimation: Animation? { + guard !reduceMotion else { return nil } + return presentation.state == .closing + ? .easeIn(duration: MikuVisualTokens.closingDuration) + : .easeOut(duration: MikuVisualTokens.openingDuration) + } + + private func finishTransientState(_ state: PresentationState) async { + switch state { + case .opening: + if !reduceMotion { + try? await Task.sleep(for: .seconds(MikuVisualTokens.openingDuration)) + } + guard !Task.isCancelled else { return } + onOpeningComplete() + case .closing: + if !reduceMotion { + try? await Task.sleep(for: .seconds(MikuVisualTokens.closingDuration)) + } + guard !Task.isCancelled else { return } + onClosingComplete() + case .idle, .terminal: + break + } + } +} diff --git a/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift b/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift index a036633..32bcb08 100644 --- a/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift +++ b/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift @@ -21,6 +21,19 @@ struct TerminalBuffer: Sendable { private var utf8Decoder = TerminalUTF8Decoder() private(set) var isBracketedPasteEnabled = false + // Per-line memoization of `presentationLine` for the primary screen so that a + // per-flush `snapshot` only re-materializes lines that actually changed instead + // of every line in scrollback. `scrollbackEvicted` counts primary lines removed + // from the front; combined with `cacheBase` it keeps the cache aligned across + // trims so unchanged scrollback lines stay cache hits. Correctness never depends + // on the alignment: each reuse is guarded by an equality check against the cached + // input, so a stale/misaligned entry simply recomputes. + private var scrollbackEvicted = 0 + private var cacheColumns = -1 + private var cacheBase = 0 + private var cachedInputs: [TerminalLine] = [] + private var cachedOutputs: [TerminalLine] = [] + init( columns: Int = 0, rows: Int = 0, @@ -37,11 +50,51 @@ struct TerminalBuffer: Sendable { } var snapshot: TerminalSnapshot { - var lines = currentLines.map { Self.presentationLine($0, columns: columns) } - if !fixedScreen { - while lines.count > 1, lines.last?.cells.isEmpty == true, !endedWithLineFeed { lines.removeLast() } + mutating get { + var lines = isAlternateScreen + ? alternateLines.map { Self.presentationLine($0, columns: columns) } + : primaryPresentationLines() + if !fixedScreen { + while lines.count > 1, lines.last?.cells.isEmpty == true, !endedWithLineFeed { lines.removeLast() } + } + return TerminalSnapshot(lines: lines) + } + } + + // Materializes presentation lines for the primary screen, reusing cached output + // for lines whose raw content is unchanged since the previous call. The equality + // guard makes reuse always safe; `scrollbackEvicted`/`cacheBase` only bias which + // cached slot each line probes so that front-trims don't force a full recompute. + private mutating func primaryPresentationLines() -> [TerminalLine] { + let lines = primaryLines + if cacheColumns != columns { + cacheColumns = columns + cachedInputs.removeAll(keepingCapacity: true) + cachedOutputs.removeAll(keepingCapacity: true) } - return TerminalSnapshot(lines: lines) + var outputs = [TerminalLine]() + outputs.reserveCapacity(lines.count) + for index in lines.indices { + let raw = lines[index] + let cacheIndex = (scrollbackEvicted + index) - cacheBase + if cacheIndex >= 0, + cacheIndex < cachedInputs.count, + cachedInputs[cacheIndex] == raw { + outputs.append(cachedOutputs[cacheIndex]) + } else { + outputs.append(Self.presentationLine(raw, columns: columns)) + } + } + cacheBase = scrollbackEvicted + cachedInputs = lines + cachedOutputs = outputs + return outputs + } + + private mutating func invalidatePresentationCache() { + cacheColumns = -1 + cachedInputs.removeAll(keepingCapacity: true) + cachedOutputs.removeAll(keepingCapacity: true) } var visibleSnapshot: TerminalSnapshot { @@ -88,6 +141,7 @@ struct TerminalBuffer: Sendable { setCursor(row: cursorPosition.line, column: cursorPosition.column) savedCursor = self.cursorPosition } + invalidatePresentationCache() } mutating func reset() { @@ -104,6 +158,7 @@ struct TerminalBuffer: Sendable { utf8Decoder.reset() normalizeScreen() isBracketedPasteEnabled = false + invalidatePresentationCache() } mutating func ingest(_ string: String) { ingest(Data(string.utf8)) } @@ -139,6 +194,7 @@ struct TerminalBuffer: Sendable { parser.reset() utf8Decoder.reset() normalizeScreen() + invalidatePresentationCache() } mutating func recoverFromInputDiscontinuity() { @@ -284,7 +340,7 @@ struct TerminalBuffer: Sendable { alternateLines.append(TerminalLine()) } else { primaryLines.append(TerminalLine()) - if primaryLines.count > maxLines { primaryLines.removeFirst() } + if primaryLines.count > maxLines { primaryLines.removeFirst(); scrollbackEvicted += 1 } } cursorRow = currentLines.count - 1 } @@ -452,11 +508,13 @@ struct TerminalBuffer: Sendable { if firstCount == 0 { if primaryLines.count == 1 { break } primaryLines.removeFirst() + scrollbackEvicted += 1 cursorRow = max(0, cursorRow - 1) continue } if primaryLines.count > 1, firstCount <= remaining { primaryLines.removeFirst() + scrollbackEvicted += 1 remaining -= firstCount cursorRow = max(0, cursorRow - 1) } else { diff --git a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift index 1a88532..ef52291 100644 --- a/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift +++ b/Sources/MikuCodeApp/Terminal/PTY/PTYProcess.swift @@ -217,6 +217,19 @@ final class PTYProcess: @unchecked Sendable { } } + /// The process group of the interactive shell itself. `forkpty` makes the + /// child a session leader (via `login_tty`/`setsid`), so its process-group + /// id equals its pid. + var shellProcessGroup: pid_t { processID } + + /// The PTY's current foreground process group, or a negative value when the + /// descriptor is closed/unavailable. While the shell sits at its prompt this + /// equals `shellProcessGroup`; while a launched job (e.g. an agent) runs it is + /// that job's process group. Read-only probe: does not touch input/output. + func foregroundProcessGroup() -> pid_t { + tcgetpgrp(masterFileDescriptor) + } + func resize(columns: Int, rows: Int) throws { guard isProcessActive else { throw TerminalSessionError.notRunning } guard columns > 0, rows > 0 else { throw TerminalSessionError.invalidSize } diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalAttributedText.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalAttributedText.swift index a6ad0d3..627fd25 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalAttributedText.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalAttributedText.swift @@ -8,20 +8,40 @@ enum TerminalAttributedText { ) -> NSAttributedString { let result = NSMutableAttributedString() var lineStarts: [Int: Int] = [:] + var attributeCache: [TerminalStyle: [NSAttributedString.Key: Any]] = [:] + + func cachedAttributes(for style: TerminalStyle) -> [NSAttributedString.Key: Any] { + if let cached = attributeCache[style] { return cached } + let computed = attributes(for: style) + attributeCache[style] = computed + return computed + } for (offset, line) in page.lines.enumerated() { let sourceLine = page.sourceRange.lowerBound + offset lineStarts[sourceLine] = result.length + var runGlyphs = "" + var runStyle: TerminalStyle? for cell in line.cells { + if let runStyle, runStyle == cell.style { + runGlyphs += cell.glyph + } else { + if let runStyle { + result.append( + NSAttributedString(string: runGlyphs, attributes: cachedAttributes(for: runStyle)) + ) + } + runGlyphs = cell.glyph + runStyle = cell.style + } + } + if let runStyle { result.append( - NSAttributedString( - string: cell.glyph, - attributes: attributes(for: cell.style) - ) + NSAttributedString(string: runGlyphs, attributes: cachedAttributes(for: runStyle)) ) } if offset + 1 < page.lines.count { - result.append(NSAttributedString(string: "\n", attributes: attributes(for: .default))) + result.append(NSAttributedString(string: "\n", attributes: cachedAttributes(for: .default))) } } @@ -123,3 +143,30 @@ enum TerminalAttributedText { } } } + +// Retroactive Hashable conformance so full styles (including rgb colors) can key +// the per-page attribute cache. Declared here rather than synthesized because the +// conformance lives in a different file from the type declarations. +extension TerminalColor: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case let .ansi(index): + hasher.combine(0) + hasher.combine(index) + case let .rgb(red, green, blue): + hasher.combine(1) + hasher.combine(red) + hasher.combine(green) + hasher.combine(blue) + } + } +} + +extension TerminalStyle: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(foreground) + hasher.combine(background) + hasher.combine(isBold) + hasher.combine(isInverse) + } +} diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalCursorView.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalCursorView.swift index 1ad0211..46b4bff 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalCursorView.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalCursorView.swift @@ -42,6 +42,12 @@ final class TerminalCursorView: NSView { needsDisplay = true } + func updateBackingScale(_ scale: CGFloat) { + guard layer?.contentsScale != scale else { return } + layer?.contentsScale = scale + needsDisplay = true + } + override func draw(_ dirtyRect: NSRect) { guard let cursor else { return } let rect = CGRect( diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalMetalRenderer.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalMetalRenderer.swift index 0daf9f2..b02832e 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalMetalRenderer.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalMetalRenderer.swift @@ -15,8 +15,13 @@ final class TerminalMetalRenderer: NSObject, TerminalOutputRendering, MTKViewDel private let commandQueue: any MTLCommandQueue private let pipeline: any MTLRenderPipelineState private var texture: (any MTLTexture)? + private var context: CGContext? private var renderedText: NSAttributedString? + /// Exposed for tests to assert the texture is reused across content-only + /// updates and only reallocated when the layout changes. + var textureForTesting: (any MTLTexture)? { texture } + static func makeDefault() -> TerminalMetalRenderer? { guard let device = TerminalMetalCapability.defaultDevice() else { return nil } return TerminalMetalRenderer(device: device) @@ -57,19 +62,44 @@ final class TerminalMetalRenderer: NSObject, TerminalOutputRendering, MTKViewDel ) else { return viewportSize.width <= 0 || viewportSize.height <= 0 } - if textureLayout == layout, - renderedText?.isEqual(to: attributedText) == true { + // The host (`TerminalOutputHostView.update`) already dedupes content via + // `isEqual` and, on any content change, forwards a freshly built immutable + // `NSAttributedString(attributedString:)` snapshot. Because that instance is + // immutable and never reused for different content, object identity is a + // sufficient (and cheaper) skip check — no second content comparison or + // defensive copy is needed here. + if textureLayout == layout, renderedText === attributedText { return true } - guard let texture = makeTexture( + + // Reallocate the CGContext + MTLTexture only when the layout (dimensions or + // rasterScale/backing scale) changes; otherwise reuse them and update via + // `texture.replace`. + if textureLayout != layout || context == nil || texture == nil { + guard let context = makeContext(layout: layout), + let texture = makeTexture(layout: layout) + else { return false } + self.context = context + self.texture = texture + } + guard let context, let texture else { return false } + + rasterize( attributedText: attributedText, viewportSize: viewportSize, - layout: layout - ) else { return false } + layout: layout, + into: context + ) + guard let bytes = context.data else { return false } + texture.replace( + region: MTLRegionMake2D(0, 0, layout.width, layout.height), + mipmapLevel: 0, + withBytes: bytes, + bytesPerRow: layout.width * 4 + ) - self.texture = texture textureLayout = layout - renderedText = NSAttributedString(attributedString: attributedText) + renderedText = attributedText metalView.drawableSize = CGSize(width: layout.width, height: layout.height) metalView.setNeedsDisplay(metalView.bounds) return true @@ -97,25 +127,41 @@ final class TerminalMetalRenderer: NSObject, TerminalOutputRendering, MTKViewDel func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} - private func makeTexture( + private func makeContext(layout: TerminalTextureLayout) -> CGContext? { + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { return nil } + return CGContext( + data: nil, + width: layout.width, + height: layout.height, + bitsPerComponent: 8, + bytesPerRow: layout.width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + ) + } + + private func makeTexture(layout: TerminalTextureLayout) -> (any MTLTexture)? { + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .bgra8Unorm, + width: layout.width, + height: layout.height, + mipmapped: false + ) + descriptor.storageMode = .managed + descriptor.usage = .shaderRead + return device.makeTexture(descriptor: descriptor) + } + + private func rasterize( attributedText: NSAttributedString, viewportSize: CGSize, - layout: TerminalTextureLayout - ) -> (any MTLTexture)? { - let bytesPerRow = layout.width * 4 - guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext( - data: nil, - width: layout.width, - height: layout.height, - bitsPerComponent: 8, - bytesPerRow: bytesPerRow, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue - | CGBitmapInfo.byteOrder32Little.rawValue - ) - else { return nil } - + layout: TerminalTextureLayout, + into context: CGContext + ) { + // Bracket the CTM mutations with save/restore so a reused context never + // accumulates transforms across frames. + context.saveGState() context.clear(CGRect(x: 0, y: 0, width: layout.width, height: layout.height)) context.translateBy(x: 0, y: CGFloat(layout.height)) context.scaleBy(x: layout.rasterScale, y: -layout.rasterScale) @@ -132,25 +178,7 @@ final class TerminalMetalRenderer: NSObject, TerminalOutputRendering, MTKViewDel options: [.usesLineFragmentOrigin, .usesFontLeading] ) NSGraphicsContext.restoreGraphicsState() - - let descriptor = MTLTextureDescriptor.texture2DDescriptor( - pixelFormat: .bgra8Unorm, - width: layout.width, - height: layout.height, - mipmapped: false - ) - descriptor.storageMode = .managed - descriptor.usage = .shaderRead - guard let texture = device.makeTexture(descriptor: descriptor), - let bytes = context.data - else { return nil } - texture.replace( - region: MTLRegionMake2D(0, 0, layout.width, layout.height), - mipmapLevel: 0, - withBytes: bytes, - bytesPerRow: bytesPerRow - ) - return texture + context.restoreGState() } private static func makePipeline(device: any MTLDevice) -> (any MTLRenderPipelineState)? { diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputView.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputView.swift index b549655..7b4d28f 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputView.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalOutputView.swift @@ -49,7 +49,8 @@ struct SelectableTerminalOutput: NSViewRepresentable { searchResults: searchResults, activeMatchIndex: activeSearchMatchIndex ), - cursor: cursor + cursor: cursor, + pageIndex: page.pageIndex ) host.requestTerminalFocus(requestID: focusRequestID) } @@ -70,6 +71,8 @@ final class TerminalOutputHostView: NSView { private var pendingFocusRequestID = 0 private var lastRenderedViewport: (size: CGSize, backingScale: CGFloat)? private var lastReportedGridSize: (columns: Int, rows: Int)? + private var lastDisplayedPageIndex: Int? + private let lineHeightMeasurer = NSLayoutManager() private(set) var renderingBackend: TerminalRenderingBackend var onPageUp: (() -> Void)? { didSet { textView.onPageUp = onPageUp } } @@ -114,12 +117,16 @@ final class TerminalOutputHostView: NSView { renderCurrentTextIfViewportChanged() let font = NSFont.monospacedSystemFont(ofSize: MikuVisualTokens.terminalFontSize, weight: .regular) + // Line pitch derives from the font's natural line height (the same value the + // Metal/AppKit text paths lay out with) instead of a hardcoded token, so cursor + // placement and rows math stay consistent if the font size ever changes. + let lineHeight = lineHeightMeasurer.defaultLineHeight(for: font) cursorView.update( - cellSize: CGSize(width: font.maximumAdvancement.width, height: MikuVisualTokens.terminalCellLineHeight), + cellSize: CGSize(width: font.maximumAdvancement.width, height: lineHeight), padding: CGSize(width: MikuVisualTokens.terminalPaddingX, height: MikuVisualTokens.terminalPaddingY) ) let columns = max(20, Int((bounds.width - MikuVisualTokens.terminalPaddingX * 2) / max(1, font.maximumAdvancement.width))) - let rows = max(5, Int((bounds.height - MikuVisualTokens.terminalPaddingY * 2) / MikuVisualTokens.terminalCellLineHeight)) + let rows = max(5, Int((bounds.height - MikuVisualTokens.terminalPaddingY * 2) / lineHeight)) let gridSize = (columns, rows) guard lastReportedGridSize?.columns != columns || lastReportedGridSize?.rows != rows else { return @@ -132,6 +139,7 @@ final class TerminalOutputHostView: NSView { super.viewDidChangeBackingProperties() lastRenderedViewport = nil renderCurrentTextIfViewportChanged() + cursorView.updateBackingScale(window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2) } override func viewDidMoveToWindow() { @@ -172,8 +180,15 @@ final class TerminalOutputHostView: NSView { } } - func update(attributedText: NSAttributedString, cursor: TerminalCursor? = nil) { + func update(attributedText: NSAttributedString, cursor: TerminalCursor? = nil, pageIndex: Int = 0) { cursorView.update(cursor: cursor) + // Only reset scroll when the displayed page actually changes; live streaming + // output on the same page must not fight the user's manual scroll each tick. + let pageChanged = lastDisplayedPageIndex != pageIndex + lastDisplayedPageIndex = pageIndex + if pageChanged { + scrollView.contentView.scroll(to: .zero) + } guard !(lastAttributedText?.isEqual(to: attributedText) ?? false) else { return } let storedText = NSAttributedString(attributedString: attributedText) lastAttributedText = storedText @@ -186,7 +201,6 @@ final class TerminalOutputHostView: NSView { textView.textStorage?.setAttributedString(storedText) } textView.restoreSelection(selection, textLength: storedText.length) - scrollView.contentView.scroll(to: .zero) } private func configureSubviews() { diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift index cfd4025..b1fde38 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalPanel.swift @@ -138,8 +138,6 @@ struct TerminalPanel: View { .accessibilityElement(children: .contain) .accessibilityLabel("Local shell terminal") .accessibilityHint("Direct keyboard input. Output is selectable.") - .contentShape(Rectangle()) - .onTapGesture(perform: onFocus) } private var visibleCursor: TerminalCursor? { @@ -315,6 +313,7 @@ struct TerminalResizeHandles: View { handle(.right) .frame(width: MikuVisualTokens.terminalResizeHitSize) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + .padding(.trailing, SpeechBubbleShape.maxTailWidth) handle(.topLeft) .frame( width: MikuVisualTokens.terminalResizeHitSize * 2, @@ -327,6 +326,7 @@ struct TerminalResizeHandles: View { height: MikuVisualTokens.terminalResizeHitSize * 2 ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing, SpeechBubbleShape.maxTailWidth) handle(.bottomLeft) .frame( width: MikuVisualTokens.terminalResizeHitSize * 2, @@ -339,6 +339,7 @@ struct TerminalResizeHandles: View { height: MikuVisualTokens.terminalResizeHitSize * 2 ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .padding(.trailing, SpeechBubbleShape.maxTailWidth) } .accessibilityHidden(true) } @@ -447,8 +448,12 @@ struct TerminalHeader: View { } struct SpeechBubbleShape: Shape { + // Maximum trailing inset the tail carves out of the bubble body; content and + // trailing-aligned controls must stay clear of this zone. + static let maxTailWidth: CGFloat = 40 + func path(in rect: CGRect) -> Path { - let tailWidth = min(40, rect.width * 0.055) + let tailWidth = min(Self.maxTailWidth, rect.width * 0.055) let tailHalfHeight = min(34, rect.height * 0.055) let radius = min(30, rect.height * 0.08) let bubbleMaxX = rect.maxX - tailWidth diff --git a/Sources/MikuCodeApp/Terminal/Rendering/TerminalWorkspaceView.swift b/Sources/MikuCodeApp/Terminal/Rendering/TerminalWorkspaceView.swift index a64412f..47d9d24 100644 --- a/Sources/MikuCodeApp/Terminal/Rendering/TerminalWorkspaceView.swift +++ b/Sources/MikuCodeApp/Terminal/Rendering/TerminalWorkspaceView.swift @@ -239,8 +239,6 @@ struct TerminalPaneView: View { Rectangle().fill(Color.black.opacity(0.08)) } } - .contentShape(Rectangle()) - .onTapGesture(perform: onFocus) } } diff --git a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift index 1b05dc9..1b059ae 100644 --- a/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift +++ b/Sources/MikuCodeApp/Terminal/Session/TerminalSessionModel.swift @@ -9,24 +9,35 @@ final class TerminalSessionModel: ObservableObject { @Published private(set) var isRunning = false @Published private(set) var isBracketedPasteEnabled = false var onInputDeliveryUpdate: ((TerminalInputDeliveryUpdate) -> Void)? + /// Fires after each non-empty output flush. Used by the agent workspace to + /// re-check run completion (the shell redrawing its prompt is itself output). + var onOutputFlush: (() -> Void)? private var buffer = TerminalBuffer(columns: 80, rows: 24) private var lifecycle = TerminalSessionLifecycle() private var process: PTYProcess? private var generation = 0 + private var pendingFlush: Task? + private var lastFlushInstant: ContinuousClock.Instant? + private static let minimumFlushInterval: Duration = .milliseconds(16) // ~60Hz private var lastSize: (columns: Int, rows: Int)? private var workingDirectory: String? private var pendingWorkingDirectoryInput = Data() private let pendingOutput = PendingTerminalOutputQueue() private let persistence: TerminalSessionPersistence private let shell: String + /// Thread-specific spawn directory. Used when no persisted state restores a + /// tracked working directory; nil keeps the default (~/Desktop when present). + private let initialWorkingDirectory: String? init( persistence: TerminalSessionPersistence = TerminalSessionPersistence(), - shell: String? = nil + shell: String? = nil, + initialWorkingDirectory: String? = nil ) { self.persistence = persistence self.shell = shell ?? Self.userShell + self.initialWorkingDirectory = initialWorkingDirectory } func start() { @@ -37,12 +48,12 @@ final class TerminalSessionModel: ObservableObject { do { if let state = try persistence.load(shell: shell) { lastSize = (state.columns, state.rows) - workingDirectory = state.workingDirectory + workingDirectory = state.workingDirectory ?? initialWorkingDirectory buffer.resize(columns: state.columns, rows: state.rows) buffer.restore(snapshot: state.snapshot, cursorPosition: state.cursorPosition) } else { lastSize = (80, 24) - workingDirectory = nil + workingDirectory = initialWorkingDirectory buffer.reset() buffer.resize(columns: 80, rows: 24) if !persistence.hasShownWelcome() { @@ -56,7 +67,7 @@ final class TerminalSessionModel: ObservableObject { reportPersistenceError("load", error: error) buffer.reset() lastSize = (80, 24) - workingDirectory = nil + workingDirectory = initialWorkingDirectory buffer.resize(columns: 80, rows: 24) isBracketedPasteEnabled = buffer.isBracketedPasteEnabled publishSnapshot() @@ -76,7 +87,7 @@ final class TerminalSessionModel: ObservableObject { if pendingOutput.append(data) { Task { @MainActor [weak self] in guard self?.generation == currentGeneration else { return } - self?.flushPendingOutput() + self?.requestFlush() } } }, @@ -210,7 +221,32 @@ final class TerminalSessionModel: ObservableObject { onInputDeliveryUpdate?(.sessionEnded) } + // Coalesces bursty PTY output to ~60Hz: flushes immediately when at least a + // frame has elapsed since the last flush, otherwise schedules a single deferred + // flush so an idle stream still drains promptly (within one frame). stop()/exit + // call flushPendingOutput() directly for a synchronous, lossless final drain. + private func requestFlush() { + guard pendingFlush == nil else { return } + let now = ContinuousClock.now + if let last = lastFlushInstant, last.duration(to: now) < Self.minimumFlushInterval { + let delay = Self.minimumFlushInterval - last.duration(to: now) + let currentGeneration = generation + pendingFlush = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self, !Task.isCancelled else { return } + self.pendingFlush = nil + guard self.generation == currentGeneration else { return } + self.flushPendingOutput() + } + } else { + flushPendingOutput() + } + } + private func flushPendingOutput() { + pendingFlush?.cancel() + pendingFlush = nil + lastFlushInstant = ContinuousClock.now let batch = pendingOutput.takeAll() process?.resumeOutputReading() guard !batch.chunks.isEmpty else { @@ -225,6 +261,18 @@ final class TerminalSessionModel: ObservableObject { } isBracketedPasteEnabled = batch.isBracketedPasteEnabled publishSnapshot() + onOutputFlush?() + } + + /// True while a foreground command other than the interactive shell owns the + /// PTY. The agent workspace uses this (checked when output flushes) to detect + /// that an agent run has finished — the foreground process group returns to + /// the shell once the launched command exits. Read-only probe. + func isForegroundCommandRunning() -> Bool { + guard let process else { return false } + let foreground = process.foregroundProcessGroup() + guard foreground > 0 else { return false } + return foreground != process.shellProcessGroup } private func didExit(status: Int32) { @@ -364,6 +412,7 @@ final class PendingTerminalOutputQueue: @unchecked Sendable { private var chunks: [Data] = [] private var byteCount = 0 private var hasDiscontinuity = false + private var droppedSinceLastRetained = false private var protocolState = TerminalProtocolState() init(maximumByteCount: Int = 1024 * 1024) { @@ -384,6 +433,7 @@ final class PendingTerminalOutputQueue: @unchecked Sendable { chunks.removeAll(keepingCapacity: true) byteCount = 0 hasDiscontinuity = false + droppedSinceLastRetained = false protocolState.reset(bracketedPasteEnabled: bracketedPasteEnabled) } @@ -393,7 +443,17 @@ final class PendingTerminalOutputQueue: @unchecked Sendable { defer { lock.unlock() } let wasEmpty = byteCount == 0 - guard data.count <= maximumByteCount - byteCount else { return false } + guard data.count <= maximumByteCount - byteCount else { + // Remember the drop: the retained stream now has a gap. Flag it on the + // next chunk we actually keep (below), so the parser resets across the + // discontinuity instead of decoding post-drop bytes as a continuation. + droppedSinceLastRetained = true + return false + } + if droppedSinceLastRetained { + hasDiscontinuity = true + droppedSinceLastRetained = false + } protocolState.consume(data) chunks.append(data) byteCount += data.count diff --git a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift index 123def9..677e90f 100644 --- a/Tests/MikuCodeAppTests/AgentRunRequestTests.swift +++ b/Tests/MikuCodeAppTests/AgentRunRequestTests.swift @@ -39,6 +39,182 @@ final class AgentRunRequestTests: XCTestCase { func testRejectsUnsupportedProviderAndTerminalControlCharacters() { XCTAssertNil(AgentRunRequest(provider: .pi, approval: .auto, prompt: "inspect")) XCTAssertNil(AgentRunRequest(provider: .codex, approval: .accept, prompt: "stop\u{0003}now")) + // ESC (used by terminal escape sequences) must still be rejected even + // though newline and tab are now allowed. + XCTAssertNil(AgentRunRequest(provider: .codex, approval: .accept, prompt: "esc\u{001B}[2J")) + } + + func testAllowsMultiLinePromptAndQuotesNewlinesLiterally() throws { + let request = try XCTUnwrap(AgentRunRequest( + provider: .codex, + approval: .accept, + prompt: "first line\nsecond line\twith tab" + )) + + // POSIX single quotes keep newline and tab literal, so the two-line prompt + // survives the shell wrapping intact as a single argument. + XCTAssertEqual( + request.shellCommand, + "codex exec --sandbox workspace-write 'first line\nsecond line\twith tab'" + ) + } + + func testRunGateBlocksSecondSubmissionWhileAgentRuns() { + let probe = ForegroundStateProbe() + let workspace = AgentWorkspaceModel( + startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) + }, + isForegroundCommandRunning: { _ in probe.isRunning } + ) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "run A" + + workspace.submit(to: terminal) + + XCTAssertEqual(workspace.runLifecycle, .running) + XCTAssertFalse(workspace.isRunGateOpen) + XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["run A"]) + + workspace.prompt = "run B" + workspace.submit(to: terminal) + + XCTAssertEqual(workspace.submittedPrompts.map(\.text), ["run A"]) + XCTAssertEqual(workspace.submissionError, "An agent run is already in progress.") + } + + func testRunReturnsToIdleWhenForegroundReturnsToShell() { + let probe = ForegroundStateProbe() + let workspace = AgentWorkspaceModel( + startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) + }, + isForegroundCommandRunning: { _ in probe.isRunning } + ) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "run A" + workspace.submit(to: terminal) + + // Startup race: the shell echoes the command line before it forks the + // agent, so foreground is still the shell — this must NOT end the run. + probe.isRunning = false + terminal.onOutputFlush?() + XCTAssertEqual(workspace.runLifecycle, .running) + + // Agent takes the PTY foreground. + probe.isRunning = true + terminal.onOutputFlush?() + XCTAssertEqual(workspace.runLifecycle, .running) + + // Agent exits, the shell reclaims the foreground and redraws its prompt. + probe.isRunning = false + terminal.onOutputFlush?() + XCTAssertEqual(workspace.runLifecycle, .idle) + XCTAssertTrue(workspace.isRunGateOpen) + } + + func testRunResolvesAfterStartupGraceWhenAgentNeverTakesForeground() { + let probe = ForegroundStateProbe() + probe.isRunning = false + let workspace = AgentWorkspaceModel( + startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) + }, + isForegroundCommandRunning: { _ in probe.isRunning }, + runStartupGrace: .zero + ) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "run missing-binary" + workspace.submit(to: terminal) + XCTAssertEqual(workspace.runLifecycle, .running) + + // The binary never launches ("command not found"): foreground stays the + // shell on every flush. Past the startup grace the run must resolve + // instead of locking the composer forever. + terminal.onOutputFlush?() + XCTAssertEqual(workspace.runLifecycle, .idle) + XCTAssertTrue(workspace.isRunGateOpen) + } + + func testRunStaysRunningWithinStartupGraceWhileForegroundIsStillShell() { + let probe = ForegroundStateProbe() + probe.isRunning = false + let workspace = AgentWorkspaceModel( + startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) + }, + isForegroundCommandRunning: { _ in probe.isRunning }, + runStartupGrace: .seconds(60) + ) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "run A" + workspace.submit(to: terminal) + + // Within the grace window a shell-foreground flush is still the startup + // race, not a completion. + terminal.onOutputFlush?() + XCTAssertEqual(workspace.runLifecycle, .running) + XCTAssertFalse(workspace.isRunGateOpen) + } + + func testQueuedPromptStartsRunOnlyAfterDelivery() throws { + let probe = ForegroundStateProbe() + let workspace = AgentWorkspaceModel( + startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .queued, deliveryToken: UUID()) + }, + isForegroundCommandRunning: { _ in probe.isRunning } + ) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "queued run" + workspace.submit(to: terminal) + + XCTAssertEqual(workspace.runLifecycle, .idle) + XCTAssertFalse(workspace.isRunGateOpen) + + let token = try XCTUnwrap(workspace.pendingPromptDeliveryToken) + terminal.onInputDeliveryUpdate?(.delivered(token)) + + XCTAssertEqual(workspace.runLifecycle, .running) + XCTAssertFalse(workspace.isRunGateOpen) + } + + func testSessionEndResetsRunToIdle() { + let probe = ForegroundStateProbe() + probe.isRunning = true + let workspace = AgentWorkspaceModel( + startRequest: { _, _ in + TerminalAgentInputSubmission(delivery: .delivered, deliveryToken: UUID()) + }, + isForegroundCommandRunning: { _ in probe.isRunning } + ) + let terminal = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + workspace.prompt = "run A" + workspace.submit(to: terminal) + XCTAssertEqual(workspace.runLifecycle, .running) + + terminal.onInputDeliveryUpdate?(.sessionEnded) + + XCTAssertEqual(workspace.runLifecycle, .idle) + XCTAssertTrue(workspace.isRunGateOpen) } func testDoesNotRecordPromptUntilLocalTerminalStarts() { @@ -124,3 +300,8 @@ final class AgentRunRequestTests: XCTestCase { XCTAssertNotNil(workspace.pendingPrompt) } } + +@MainActor +private final class ForegroundStateProbe { + var isRunning = false +} diff --git a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift index 052328f..b69a46a 100644 --- a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift +++ b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift @@ -25,7 +25,7 @@ final class AppShellPolicyTests: XCTestCase { XCTAssertEqual(coordinator.panel.level, .floating) } - func testCoordinatorRetainsOnePanelAcrossOpenAndClose() { + func testCoordinatorRetainsOnePanelAcrossOpenAndClose() throws { let session = TerminalSessionModel( persistence: TerminalSessionPersistence(url: nil), shell: "/bin/sh" @@ -50,14 +50,17 @@ final class AppShellPolicyTests: XCTestCase { ) coordinator.requestOpen() XCTAssertTrue(originalPanel === coordinator.panel) - XCTAssertFalse(originalPanel.styleMask.contains(.nonactivatingPanel)) - XCTAssertFalse(originalPanel.styleMask.contains(.titled)) - XCTAssertEqual(originalPanel.level, .normal) - XCTAssertFalse(originalPanel.collectionBehavior.contains(.fullScreenAuxiliary)) + // The idle companion keeps its non-activating overlay configuration; + // the presented workspace is a separate standard window. + XCTAssertTrue(originalPanel.styleMask.contains(.nonactivatingPanel)) + let workspace = try XCTUnwrap(coordinator.workspaceWindow) + XCTAssertEqual(workspace.level, .normal) coordinator.requestClose() coordinator.completeClosing() XCTAssertTrue(originalPanel === coordinator.panel) + XCTAssertTrue(workspace === coordinator.workspaceWindow) + XCTAssertFalse(workspace.isVisible) XCTAssertTrue(originalPanel.styleMask.contains(.nonactivatingPanel)) XCTAssertFalse(originalPanel.ignoresMouseEvents) } @@ -76,6 +79,41 @@ final class AppShellPolicyTests: XCTestCase { XCTAssertTrue(root.hitTest(CGPoint(x: 200, y: 200)) === hostedContent) } + func testIdleHitTestConvertsClickIntoFlippedRegionSpace() throws { + // Install the overlay in a real window under an unflipped superview so + // hitTest receives points in bottom-left superview space, exactly as + // AppKit delivers them. The region hugs the TOP of the flipped overlay, + // so Miku's true on-screen click and its vertical mirror land on + // opposite sides of it — diverging only once hitTest converts the + // incoming point into this flipped view's space. + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + let container = try XCTUnwrap(window.contentView) + let root = OverlayHitTestView(frame: container.bounds) + let hostedContent = NSView(frame: root.bounds) + root.addSubview(hostedContent) + container.addSubview(root) + root.idlePassthroughEnabled = true + root.interactiveRegion = CGRect(x: 600, y: 40, width: 160, height: 120) + + // Region center (680, 100) in the overlay's flipped space maps to the + // superview point (680, 600 - 100) = (680, 500): Miku's real position. + XCTAssertTrue( + root.hitTest(CGPoint(x: 680, y: 500)) === hostedContent, + "A click at Miku's real on-screen position must hit the overlay." + ) + // The vertically mirrored point (680, 100) only matches when the region + // is read in the wrong (unconverted) space. + XCTAssertNil( + root.hitTest(CGPoint(x: 680, y: 100)), + "The vertically mirrored point must pass through, not hit Miku." + ) + } + func testDockRevealPromotesOnlyIdleMikuUntilTheAppLosesFocus() { let coordinator = MikuPanelCoordinator( screenFrame: CGRect(x: 0, y: 0, width: 1_280, height: 800), @@ -163,11 +201,11 @@ final class AppShellPolicyTests: XCTestCase { )) coordinator.panel.sendEvent(down) - XCTAssertFalse(coordinator.panel.isTerminalPresented) + XCTAssertNil(coordinator.workspaceWindow) coordinator.panel.sendEvent(up) - XCTAssertTrue(coordinator.panel.isTerminalPresented) - XCTAssertEqual(coordinator.panel.level, .normal) + let workspace = try XCTUnwrap(coordinator.workspaceWindow) + XCTAssertEqual(workspace.level, .normal) coordinator.completeOpening() XCTAssertTrue(session.isRunning) } @@ -195,7 +233,7 @@ final class AppShellPolicyTests: XCTestCase { coordinator.panel.sendEvent(event) - XCTAssertFalse(coordinator.panel.isTerminalPresented) + XCTAssertNil(coordinator.workspaceWindow) XCTAssertTrue(coordinator.panel.styleMask.contains(.nonactivatingPanel)) } @@ -209,19 +247,17 @@ final class AppShellPolicyTests: XCTestCase { ordersFront: false ) - XCTAssertNil(coordinator.panel.onNewTab) - XCTAssertNil(coordinator.panel.onSplitRight) - XCTAssertNil(coordinator.panel.onSplitBottom) + coordinator.requestOpen() + + XCTAssertNil(coordinator.workspaceWindow?.onNewTab) + XCTAssertNil(coordinator.workspaceWindow?.onSplitRight) + XCTAssertNil(coordinator.workspaceWindow?.onSplitBottom) } - func testAgentPanelDoesNotConsumeWorkspaceShortcutsWithoutHandlers() throws { - let panel = MikuPanel( - contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), - styleMask: [.borderless], - backing: .buffered, - defer: false + func testWorkspaceWindowDoesNotConsumeWorkspaceShortcutsWithoutHandlers() throws { + let window = WorkspaceWindow( + contentRect: CGRect(x: 0, y: 0, width: 960, height: 640) ) - panel.isTerminalPresented = true let commandT = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, location: .zero, @@ -235,18 +271,15 @@ final class AppShellPolicyTests: XCTestCase { keyCode: 17 )) - XCTAssertFalse(panel.performKeyEquivalent(with: commandT)) + XCTAssertFalse(window.performKeyEquivalent(with: commandT)) } - func testCommandWRoutesToCloseWithoutClosingPanel() throws { - let panel = MikuPanel( - contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), - styleMask: [.borderless], - backing: .buffered, - defer: false + func testCommandWRoutesToCloseWithoutClosingWorkspaceWindow() throws { + let window = WorkspaceWindow( + contentRect: CGRect(x: 0, y: 0, width: 960, height: 640) ) var closeRequests = 0 - panel.onCloseRequest = { closeRequests += 1 } + window.onCloseRequest = { closeRequests += 1 } let event = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, location: .zero, @@ -260,9 +293,49 @@ final class AppShellPolicyTests: XCTestCase { keyCode: 13 )) - XCTAssertTrue(panel.performKeyEquivalent(with: event)) + XCTAssertTrue(window.performKeyEquivalent(with: event)) XCTAssertEqual(closeRequests, 1) - XCTAssertFalse(panel.isReleasedWhenClosed) + XCTAssertFalse(window.isReleasedWhenClosed) + } + + func testWorkspaceWindowIsAStandardTransparentWindow() throws { + let session = TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + let coordinator = MikuPanelCoordinator( + screenFrame: CGRect(x: 0, y: 0, width: 1_280, height: 800), + terminalSession: session, + ordersFront: false + ) + + coordinator.requestOpen() + coordinator.completeOpening() + XCTAssertTrue(session.isRunning) + let window = try XCTUnwrap(coordinator.workspaceWindow) + + XCTAssertTrue(window.styleMask.contains(.titled)) + XCTAssertTrue(window.styleMask.contains(.closable)) + XCTAssertTrue(window.styleMask.contains(.miniaturizable)) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertTrue(window.styleMask.contains(.fullSizeContentView)) + XCTAssertTrue(window.collectionBehavior.contains(.fullScreenPrimary)) + XCTAssertFalse(window.isOpaque) + XCTAssertEqual(window.backgroundColor, .clear) + XCTAssertEqual(window.titleVisibility, .hidden) + XCTAssertTrue(window.titlebarAppearsTransparent) + // The tall-titlebar accessory grows the hidden titlebar so the traffic + // lights sit lower, toward the bubble header's vertical center. AppKit + // owns the accessory's final frame, so only its presence is asserted. + XCTAssertEqual(window.titlebarAccessoryViewControllers.count, 1) + XCTAssertNotNil(window.standardWindowButton(.closeButton)) + XCTAssertNotNil(window.standardWindowButton(.zoomButton)) + + // The red close button must return to idle, never terminate the app. + XCTAssertFalse(coordinator.windowShouldClose(window)) + coordinator.completeClosing() + XCTAssertFalse(window.isVisible) + XCTAssertFalse(session.isRunning) } func testTerminalHostTakesDirectFirstResponderFocus() { diff --git a/Tests/MikuCodeAppTests/TerminalAttributedTextTests.swift b/Tests/MikuCodeAppTests/TerminalAttributedTextTests.swift new file mode 100644 index 0000000..f1057f3 --- /dev/null +++ b/Tests/MikuCodeAppTests/TerminalAttributedTextTests.swift @@ -0,0 +1,116 @@ +import AppKit +import XCTest +@testable import MikuCodeApp + +@MainActor +final class TerminalAttributedTextTests: XCTestCase { + // Locks the invariant that coalescing consecutive same-style cells produces + // glyph content and character offsets byte-identical to a per-cell build, so + // search-highlight ranges and lineStarts stay correct. + func testCoalescingPreservesGlyphContentAndOffsets() { + let defaultStyle = TerminalStyle.default + let boldStyle = TerminalStyle(foreground: nil, background: nil, isBold: true, isInverse: false) + let rgbStyle = TerminalStyle( + foreground: .rgb(10, 20, 30), + background: nil, + isBold: false, + isInverse: false + ) + + let line0 = TerminalLine(cells: [ + TerminalCell(glyph: "H", style: defaultStyle), + TerminalCell(glyph: "i", style: defaultStyle), + TerminalCell(glyph: "!", style: boldStyle) + ]) + let line1 = TerminalLine(cells: [ + TerminalCell(glyph: "R", style: rgbStyle), + TerminalCell(glyph: "G", style: rgbStyle), + TerminalCell(glyph: "B", style: rgbStyle), + TerminalCell(glyph: "x", style: defaultStyle) + ]) + let lines = [line0, line1] + let page = TerminalPageSlice( + lines: lines, + pageIndex: 0, + pageCount: 1, + sourceRange: 0 ..< 2 + ) + let searchResults = TerminalSearchResults(lines: lines, query: "GB") + let attributed = TerminalAttributedText.make( + page: page, + searchResults: searchResults, + activeMatchIndex: nil + ) + + // Byte-identical glyph content, including the inter-line newline. + XCTAssertEqual(attributed.string, "Hi!\nRGBx") + + // The "GB" match sits on line 1, column 1. line1 starts at offset 4 + // (len("Hi!") == 3 + newline). A shifted offset from bad coalescing would + // land the highlight in the wrong place. + var highlightRange = NSRange(location: 0, length: 0) + let highlight = attributed.attribute( + .backgroundColor, + at: 5, + longestEffectiveRange: &highlightRange, + in: NSRange(location: 0, length: attributed.length) + ) + XCTAssertNotNil(highlight) + XCTAssertEqual(highlightRange, NSRange(location: 5, length: 2)) + + // No highlight bleeds onto line 0. + XCTAssertNil(attributed.attribute(.backgroundColor, at: 0, effectiveRange: nil)) + + // Coalescing must not merge across style boundaries: "!" stays bold while + // "H" stays regular. + let regularFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? NSFont + let boldFont = attributed.attribute(.font, at: 2, effectiveRange: nil) as? NSFont + XCTAssertEqual(regularFont?.pointSize, MikuVisualTokens.terminalFontSize) + XCTAssertFalse( + regularFont?.fontDescriptor.symbolicTraits.contains(.bold) ?? true + ) + XCTAssertTrue( + boldFont?.fontDescriptor.symbolicTraits.contains(.bold) ?? false + ) + } + + // Offsets/lineStarts must remain correct even when the page is a mid-buffer + // slice (sourceRange not starting at 0), which is how search matches key in. + func testHighlightOffsetsRespectSourceRange() { + let style = TerminalStyle.default + func line(_ text: String) -> TerminalLine { + TerminalLine(cells: text.map { TerminalCell(glyph: String($0), style: style) }) + } + // Full buffer: "needle" lands on absolute line index 6. + let allLines = [ + line("zero"), line("one"), line("two"), line("three"), + line("four"), line("abc"), line("needle") + ] + // Slice represents source lines 5 and 6. + let page = TerminalPageSlice( + lines: Array(allLines[5 ..< 7]), + pageIndex: 1, + pageCount: 2, + sourceRange: 5 ..< 7 + ) + let searchResults = TerminalSearchResults(lines: allLines, query: "needle") + XCTAssertEqual(searchResults.matches, [TerminalSearchMatch(lineIndex: 6, column: 0, length: 6)]) + + let attributed = TerminalAttributedText.make( + page: page, + searchResults: searchResults, + activeMatchIndex: 0 + ) + + XCTAssertEqual(attributed.string, "abc\nneedle") + // line index 6 → offset 1 in slice → starts at len("abc")+1 == 4. + var highlightRange = NSRange(location: 0, length: 0) + _ = attributed.attribute( + .backgroundColor, + at: 4, + longestEffectiveRange: &highlightRange, + in: NSRange(location: 0, length: attributed.length) + ) + XCTAssertEqual(highlightRange, NSRange(location: 4, length: 6)) + } +} diff --git a/Tests/MikuCodeAppTests/TerminalBufferTests.swift b/Tests/MikuCodeAppTests/TerminalBufferTests.swift index cf877c3..2c73c47 100644 --- a/Tests/MikuCodeAppTests/TerminalBufferTests.swift +++ b/Tests/MikuCodeAppTests/TerminalBufferTests.swift @@ -389,6 +389,40 @@ final class TerminalBufferTests: XCTestCase { XCTAssertFalse(buffer.isBracketedPasteEnabled) } + func testDroppedChunkFlagsDiscontinuityOnNextRetainedChunk() { + let queue = PendingTerminalOutputQueue(maximumByteCount: 8) + + XCTAssertTrue(queue.append(Data("1234".utf8))) + // Oversized chunk is dropped; the retained stream now has a gap. + XCTAssertFalse(queue.append(Data("oversized!!".utf8))) + // The next chunk we actually keep must carry the discontinuity so the parser + // resets across the gap instead of decoding "56" as a continuation of "1234". + XCTAssertFalse(queue.append(Data("56".utf8))) + + let batch = queue.takeAll() + XCTAssertTrue(batch.hasDiscontinuity) + XCTAssertEqual( + batch.chunks.reduce(into: Data()) { $0.append($1) }, + Data("123456".utf8) + ) + } + + func testDropWithoutSubsequentRetainedChunkDoesNotFlagDiscontinuity() { + let queue = PendingTerminalOutputQueue(maximumByteCount: 8) + + XCTAssertTrue(queue.append(Data("1234".utf8))) + XCTAssertFalse(queue.append(Data("oversized!!".utf8))) + + // A drop at the tail leaves the retained bytes self-consistent, so the first + // drain stays continuous; the pending gap surfaces on the next kept chunk. + let first = queue.takeAll() + XCTAssertFalse(first.hasDiscontinuity) + + XCTAssertTrue(queue.append(Data("56".utf8))) + let second = queue.takeAll() + XCTAssertTrue(second.hasDiscontinuity) + } + func testProtocolModeFailsClosedOnOversizedCSI() { var state = TerminalProtocolState() state.consume(Data("\u{1B}[?2004h".utf8)) diff --git a/Tests/MikuCodeAppTests/TerminalMetalRendererTests.swift b/Tests/MikuCodeAppTests/TerminalMetalRendererTests.swift index e19bae6..335e743 100644 --- a/Tests/MikuCodeAppTests/TerminalMetalRendererTests.swift +++ b/Tests/MikuCodeAppTests/TerminalMetalRendererTests.swift @@ -50,6 +50,71 @@ final class TerminalMetalRendererTests: XCTestCase { XCTAssertFalse(metalView.autoResizeDrawable) } + func testRendererReusesTextureUntilLayoutChanges() throws { + guard let device = MTLCreateSystemDefaultDevice() else { + throw XCTSkip("No Metal device is available on this Mac") + } + let renderer = try XCTUnwrap(TerminalMetalRenderer(device: device)) + let viewport = CGSize(width: 120, height: 40) + + XCTAssertTrue( + renderer.render( + attributedText: NSAttributedString(string: "first frame"), + viewportSize: viewport, + backingScale: 2 + ) + ) + let firstTexture = try XCTUnwrap(renderer.textureForTesting) as AnyObject + + XCTAssertTrue( + renderer.render( + attributedText: NSAttributedString(string: "second frame content"), + viewportSize: viewport, + backingScale: 2 + ) + ) + let reusedTexture = try XCTUnwrap(renderer.textureForTesting) as AnyObject + XCTAssertTrue( + firstTexture === reusedTexture, + "Content-only updates at the same layout must reuse the texture via replace()" + ) + + XCTAssertTrue( + renderer.render( + attributedText: NSAttributedString(string: "third frame"), + viewportSize: CGSize(width: 200, height: 40), + backingScale: 2 + ) + ) + let reallocatedTexture = try XCTUnwrap(renderer.textureForTesting) as AnyObject + XCTAssertFalse( + reusedTexture === reallocatedTexture, + "A layout change must reallocate the texture" + ) + } + + func testRendererSkipsWorkWhenTextAndLayoutAreUnchanged() throws { + guard let device = MTLCreateSystemDefaultDevice() else { + throw XCTSkip("No Metal device is available on this Mac") + } + let renderer = try XCTUnwrap(TerminalMetalRenderer(device: device)) + let viewport = CGSize(width: 120, height: 40) + let attributedText = NSAttributedString(string: "stable content") + + XCTAssertTrue( + renderer.render(attributedText: attributedText, viewportSize: viewport, backingScale: 2) + ) + let firstTexture = try XCTUnwrap(renderer.textureForTesting) as AnyObject + let layout = renderer.textureLayout + + XCTAssertTrue( + renderer.render(attributedText: attributedText, viewportSize: viewport, backingScale: 2) + ) + let sameTexture = try XCTUnwrap(renderer.textureForTesting) as AnyObject + XCTAssertTrue(firstTexture === sameTexture) + XCTAssertEqual(layout, renderer.textureLayout) + } + func testHostFallsBackWhenRendererCannotCreateTexture() { let renderer = FailingTerminalRenderer() let host = TerminalOutputHostView( diff --git a/Tests/MikuCodeAppTests/TerminalVisualSmokeTests.swift b/Tests/MikuCodeAppTests/TerminalVisualSmokeTests.swift index 34d3588..1594ed1 100644 --- a/Tests/MikuCodeAppTests/TerminalVisualSmokeTests.swift +++ b/Tests/MikuCodeAppTests/TerminalVisualSmokeTests.swift @@ -47,15 +47,10 @@ final class TerminalVisualSmokeTests: XCTestCase { } } - func testResizedTerminalBubbleRendersWithinBounds() throws { - let displaySize = CGSize(width: 1_440, height: 900) - let defaultFrame = PresentationLayout.metrics(in: displaySize).terminalFrame - let frame = TerminalBubbleLayout.resized( - defaultFrame, - by: CGSize(width: 160, height: 120), - edge: .bottomRight, - within: TerminalBubbleLayout.resizeBounds(in: displaySize) - ) + func testResizedWorkspaceWindowRendersAdaptedBubble() throws { + // Native window resizing replaced the in-bubble drag handles: the + // workspace layout must adapt to whatever size the window takes. + let windowSize = CGSize(width: 1_000, height: 640) let session = TerminalSessionModel( persistence: TerminalSessionPersistence(url: nil), shell: "/bin/sh" @@ -65,17 +60,13 @@ final class TerminalVisualSmokeTests: XCTestCase { _ = presentation.finishOpening() defer { session.stop() } let bitmap = try render( - root( - presentation: presentation, - session: session, - terminalFrameOverride: frame - ), - size: displaySize + workspaceRoot(presentation: presentation, session: session), + size: windowSize ) - XCTAssertEqual(bitmap.pixelsWide, Int(displaySize.width)) - XCTAssertEqual(bitmap.pixelsHigh, Int(displaySize.height)) - XCTAssertGreaterThan(nontransparentSampleRatio(in: bitmap), 0.90) + XCTAssertEqual(bitmap.pixelsWide, Int(windowSize.width)) + XCTAssertEqual(bitmap.pixelsHigh, Int(windowSize.height)) + XCTAssertGreaterThan(nontransparentSampleRatio(in: bitmap), 0.70) if let captureDirectory = ProcessInfo.processInfo.environment["MIKUCODE_CAPTURE_DIR"] { let directory = URL(fileURLWithPath: captureDirectory, isDirectory: true) @@ -117,7 +108,7 @@ final class TerminalVisualSmokeTests: XCTestCase { ) let presentation = AppPresentationStore(terminalLifecycle: session) let idleBitmap = try render( - root(presentation: presentation, session: session), + idleRoot(presentation: presentation), size: size ) @@ -134,11 +125,13 @@ final class TerminalVisualSmokeTests: XCTestCase { session.snapshot.plainText.contains(terminalMessage) } let terminalBitmap = try render( - root(presentation: presentation, session: session), + workspaceRoot(presentation: presentation, session: session), size: size ) - XCTAssertGreaterThan(nontransparentSampleRatio(in: terminalBitmap), 0.90) + // The bubble fills the window except the transparent Miku rail + // (~22% of the width), so most — but not all — samples are opaque. + XCTAssertGreaterThan(nontransparentSampleRatio(in: terminalBitmap), 0.70) XCTAssertTrue(session.snapshot.plainText.contains(terminalMessage)) if let captureDirectory = ProcessInfo.processInfo.environment["MIKUCODE_CAPTURE_DIR"] { @@ -156,19 +149,20 @@ final class TerminalVisualSmokeTests: XCTestCase { } } - private func root( + private func idleRoot(presentation: AppPresentationStore) -> some View { + MikuRootView(presentation: presentation, onOpen: { }) + } + + private func workspaceRoot( presentation: AppPresentationStore, - session: TerminalSessionModel, - terminalFrameOverride: CGRect? = nil + session: TerminalSessionModel ) -> some View { - MikuRootView( + WorkspaceRootView( presentation: presentation, - terminalSession: session, - onOpen: { }, + sessionStore: WorkspaceSessionStore(initialTerminal: session), onOpeningComplete: { }, onClose: { }, onClosingComplete: { }, - terminalFrameOverride: terminalFrameOverride, terminalRendererPolicy: .appKitFallback ) } diff --git a/Tests/MikuCodeAppTests/TerminalWorkspaceViewTests.swift b/Tests/MikuCodeAppTests/TerminalWorkspaceViewTests.swift index 3a8c519..dac3c06 100644 --- a/Tests/MikuCodeAppTests/TerminalWorkspaceViewTests.swift +++ b/Tests/MikuCodeAppTests/TerminalWorkspaceViewTests.swift @@ -85,65 +85,54 @@ final class TerminalWorkspaceViewTests: XCTestCase { } @MainActor - func testPanelShortcutRoutesToSplitCallbackWithoutASecondWindowOrInputPath() throws { - let panel = MikuPanel( - contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), - styleMask: [.borderless], - backing: .buffered, - defer: false + func testWorkspaceShortcutRoutesToSplitCallbackWithoutASecondWindowOrInputPath() throws { + let window = WorkspaceWindow( + contentRect: CGRect(x: 0, y: 0, width: 960, height: 640) ) - panel.isTerminalPresented = true var rightSplits = 0 var bottomSplits = 0 - panel.onSplitRight = { rightSplits += 1 } - panel.onSplitBottom = { bottomSplits += 1 } + window.onSplitRight = { rightSplits += 1 } + window.onSplitBottom = { bottomSplits += 1 } let right = try keyEvent(characters: "d", modifiers: .command) let bottom = try keyEvent(characters: "d", modifiers: [.command, .shift]) let rejected = try keyEvent(characters: "d", modifiers: [.command, .option]) - XCTAssertTrue(panel.performKeyEquivalent(with: right)) - XCTAssertTrue(panel.performKeyEquivalent(with: bottom)) - XCTAssertFalse(panel.performKeyEquivalent(with: rejected)) + XCTAssertTrue(window.performKeyEquivalent(with: right)) + XCTAssertTrue(window.performKeyEquivalent(with: bottom)) + XCTAssertFalse(window.performKeyEquivalent(with: rejected)) XCTAssertEqual(rightSplits, 1) XCTAssertEqual(bottomSplits, 1) } @MainActor - func testPanelCommandTRoutesToNewTabCallbackWithoutCreatingAWindow() throws { - let panel = MikuPanel( - contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), - styleMask: [.borderless], - backing: .buffered, - defer: false + func testWorkspaceCommandTRoutesToNewTabCallbackWithoutCreatingAWindow() throws { + let window = WorkspaceWindow( + contentRect: CGRect(x: 0, y: 0, width: 960, height: 640) ) - panel.isTerminalPresented = true var newTabs = 0 - panel.onNewTab = { newTabs += 1 } + window.onNewTab = { newTabs += 1 } let newTab = try keyEvent(characters: "t", modifiers: .command) let rejected = try keyEvent(characters: "t", modifiers: [.command, .option]) - XCTAssertTrue(panel.performKeyEquivalent(with: newTab)) - XCTAssertFalse(panel.performKeyEquivalent(with: rejected)) + XCTAssertTrue(window.performKeyEquivalent(with: newTab)) + XCTAssertFalse(window.performKeyEquivalent(with: rejected)) XCTAssertEqual(newTabs, 1) } @MainActor - func testPanelDoesNotRouteWorkspaceShortcutsWhileIdle() throws { + func testIdlePanelDoesNotRouteWorkspaceShortcuts() throws { let panel = MikuPanel( contentRect: CGRect(x: 0, y: 0, width: 640, height: 480), styleMask: [.borderless], backing: .buffered, defer: false ) - var newTabs = 0 - panel.onNewTab = { newTabs += 1 } let newTab = try keyEvent(characters: "t", modifiers: .command) XCTAssertFalse(panel.performKeyEquivalent(with: newTab)) - XCTAssertEqual(newTabs, 0) } @MainActor diff --git a/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift b/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift new file mode 100644 index 0000000..9e81124 --- /dev/null +++ b/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift @@ -0,0 +1,245 @@ +import XCTest +@testable import MikuCodeApp + +@MainActor +final class WorkspaceSessionStoreTests: XCTestCase { + private func makeTerminal() -> TerminalSessionModel { + TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ) + } + + private func makeStore( + repository: WorkspaceSessionsRepository = WorkspaceSessionsRepository(directoryURL: nil), + initialTerminal: TerminalSessionModel? = nil + ) -> WorkspaceSessionStore { + WorkspaceSessionStore( + repository: repository, + initialTerminal: initialTerminal, + shell: "/bin/sh" + ) + } + + private func makeTemporaryRepositoryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("miku-sessions-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + func testStoreWithInjectedTerminalStartsWithOneSelectedSession() { + let store = makeStore(initialTerminal: makeTerminal()) + + XCTAssertEqual(store.sessions.count, 1) + XCTAssertEqual(store.selectedID, store.sessions[0].id) + XCTAssertEqual(store.selected?.title, "New thread") + } + + func testStoreStartsEmptyWithoutInjectedTerminalOrPersistedThreads() { + let store = makeStore() + + XCTAssertTrue(store.sessions.isEmpty) + XCTAssertNil(store.selectedID) + XCTAssertNil(store.selected) + } + + func testCreateSessionAppendsAndSelectsTheNewThread() throws { + let store = makeStore(initialTerminal: makeTerminal()) + let first = try XCTUnwrap(store.selected) + + let second = store.createSession() + + XCTAssertEqual(store.sessions.count, 2) + XCTAssertEqual(store.selectedID, second.id) + XCTAssertTrue(store.selected === second) + + store.select(first.id) + XCTAssertTrue(store.selected === first) + } + + func testSelectIgnoresUnknownIDs() { + let store = makeStore(initialTerminal: makeTerminal()) + let selected = store.selectedID + + store.select(UUID()) + + XCTAssertEqual(store.selectedID, selected) + } + + func testLifecycleStartsOnlySelectedThreadAndStartsOthersOnSwitch() throws { + let store = makeStore(initialTerminal: makeTerminal()) + let first = try XCTUnwrap(store.selected) + store.select(first.id) + let second = store.createSession() + store.select(first.id) + + // Lazy start: opening the workspace only launches the selected shell. + store.start() + XCTAssertTrue(first.terminal.isRunning) + XCTAssertFalse(second.terminal.isRunning) + + // Switching threads starts the newly selected shell. + store.select(second.id) + XCTAssertTrue(second.terminal.isRunning) + + store.stop() + XCTAssertFalse(first.terminal.isRunning) + XCTAssertFalse(second.terminal.isRunning) + + // Sessions created after stop stay stopped until the next start. + let third = store.createSession() + XCTAssertFalse(third.terminal.isRunning) + } + + func testSessionTitleTracksFirstSubmittedPromptLine() throws { + let terminal = makeTerminal() + terminal.start() + defer { terminal.stop() } + let store = makeStore(initialTerminal: terminal) + let entry = try XCTUnwrap(store.selected) + + entry.agent.prompt = "fix the login bug\nthen add tests" + entry.agent.submit(to: terminal) + + XCTAssertEqual(entry.title, "fix the login bug") + } + + func testCreateSessionSpawnsShellInTheThreadWorkingDirectory() async throws { + let directory = try makeTemporaryRepositoryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = makeStore() + + let entry = store.createSession(workingDirectory: directory) + store.start() + defer { store.stop() } + XCTAssertTrue(entry.terminal.isRunning) + + entry.terminal.sendRawInput(Data("pwd\n".utf8)) + let resolved = directory.resolvingSymlinksInPath().path + // The 80-column terminal wraps long paths, so match on the unwrapped text. + func unwrappedText() -> String { + entry.terminal.snapshot.plainText.replacingOccurrences(of: "\n", with: "") + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while !unwrappedText().contains(resolved), clock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + + XCTAssertTrue( + unwrappedText().contains(resolved), + "snapshot=\(entry.terminal.snapshot.plainText.debugDescription)" + ) + } + + func testRenameSessionOverridesDerivedTitleAndPersists() throws { + let directory = try makeTemporaryRepositoryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = WorkspaceSessionsRepository(directoryURL: directory) + + let store = makeStore(repository: repository) + let entry = store.createSession() + entry.agent.restoreTranscript( + prompts: [AgentPrompt(provider: .codex, approval: .accept, text: "fix the build")], + provider: .codex, + approval: .accept + ) + store.renameSession(entry.id, to: " Release prep ") + XCTAssertEqual(entry.title, "Release prep") + + // Blank rename reverts to the prompt-derived title. + store.renameSession(entry.id, to: " ") + XCTAssertEqual(entry.title, "fix the build") + + store.renameSession(entry.id, to: "Ship it") + let restored = makeStore(repository: repository) + XCTAssertEqual(restored.sessions.first?.title, "Ship it") + } + + func testDeleteSessionRemovesThreadSnapshotAndReselectsNeighbor() throws { + let directory = try makeTemporaryRepositoryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = WorkspaceSessionsRepository(directoryURL: directory) + + let store = makeStore(repository: repository) + let first = store.createSession() + let second = store.createSession() + let third = store.createSession() + store.select(second.id) + + let snapshotURL = directory.appendingPathComponent("\(second.id.uuidString).terminal.json") + second.terminal.start() + second.terminal.stop() + XCTAssertTrue(FileManager.default.fileExists(atPath: snapshotURL.path)) + + store.deleteSession(second.id) + + XCTAssertEqual(store.sessions.map(\.id), [first.id, third.id]) + // Selection moves to the next thread in the list. + XCTAssertEqual(store.selectedID, third.id) + XCTAssertFalse(FileManager.default.fileExists(atPath: snapshotURL.path)) + + // Deleting the rest empties the store (new-thread pane state). + store.deleteSession(third.id) + store.deleteSession(first.id) + XCTAssertTrue(store.sessions.isEmpty) + XCTAssertNil(store.selectedID) + + // The deletions persist across restarts. + let restored = makeStore(repository: repository) + XCTAssertTrue(restored.sessions.isEmpty) + } + + func testThreadsPersistAcrossStoreInstances() throws { + let directory = try makeTemporaryRepositoryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = WorkspaceSessionsRepository(directoryURL: directory) + let workFolder = try makeTemporaryRepositoryDirectory() + defer { try? FileManager.default.removeItem(at: workFolder) } + + let store = makeStore(repository: repository) + let first = store.createSession(workingDirectory: workFolder) + first.terminal.start() + first.agent.prompt = "build the sidebar" + first.agent.submit(to: first.terminal) + first.terminal.stop() + let second = store.createSession() + store.select(first.id) + + let restored = makeStore(repository: repository) + XCTAssertEqual(restored.sessions.count, 2) + XCTAssertEqual(restored.selectedID, first.id) + XCTAssertEqual(restored.sessions[0].id, first.id) + XCTAssertEqual(restored.sessions[0].title, "build the sidebar") + XCTAssertEqual(restored.sessions[0].workingDirectory?.path, workFolder.path) + XCTAssertEqual(restored.sessions[1].id, second.id) + XCTAssertEqual(restored.sessions[1].title, "New thread") + XCTAssertEqual(restored.defaultWorkingDirectory?.path, workFolder.path) + // Restored threads stay stopped until the workspace opens them. + XCTAssertFalse(restored.sessions[0].terminal.isRunning) + } + + func testRestoredThreadKeepsItsTerminalScrollback() async throws { + let directory = try makeTemporaryRepositoryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let repository = WorkspaceSessionsRepository(directoryURL: directory) + + let store = makeStore(repository: repository) + let entry = store.createSession() + store.start() + entry.terminal.sendRawInput(Data("printf 'PERSIST_ME\\n'\n".utf8)) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while !entry.terminal.snapshot.plainText.contains("PERSIST_ME"), clock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + store.stop() + + let restored = makeStore(repository: repository) + let restoredEntry = try XCTUnwrap(restored.selected) + restored.start() + defer { restored.stop() } + XCTAssertTrue(restoredEntry.terminal.snapshot.plainText.contains("PERSIST_ME")) + } +}