diff --git a/DESIGN.md b/DESIGN.md index 8e03876..4bea2fd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -33,8 +33,8 @@ use the close transition and return to `idle`. reveals a small speech-bubble terminal affordance. The companion never walks, slides, or bobs in idle; only the intentional gaze state changes. Clicking either Miku herself or that bubble begins the terminal transition. -- In terminal state, the bubble opens as a coding-agent workspace: the centered - MikuCode mark gives way to prompt history, a provider/approval composer, and +- In terminal state, the bubble opens as a coding-agent workspace: a compact + MikuCode logo mark gives way to prompt history, a provider/approval composer, and a terminal toggle. Codex and Claude support `Plan`, `Accept`, and `Auto`; the workspace only records a request after its token-matched PTY delivery. The terminal toggle reveals that same local PTY inside the bubble—never a @@ -44,11 +44,17 @@ use the close transition and return to `idle`. companion at the floating layer; it only returns behind other apps after the app itself loses focus, and never quits MikuCode. - The workspace header is an agent-workspace cue, not a toolbar: it contains - the app name, an embedded-terminal toggle, and one close control. The - composer is the only persistent input surface. Command-F opens the only + the logo mark, local-shell status, an embedded-terminal toggle, and one close + control. The composer is a measured bottom dock: provider and approval chips + remain distinct from a full-width prompt field and labelled send action at all + supported bubble sizes. Codex uses a cool gray signal, Claude orange, and + Plan/Accept/Auto each retain distinct semantic colors. Command-F opens the only transient terminal text field: a compact in-bubble finder. Command-G and Shift-Command-G navigate its case-insensitive matches across output pages; Escape restores terminal focus and removes the finder. +- The speech-bubble tail owns a reserved header inset; terminal and close controls + never sit beneath it, and the focused-Miku close target begins after a fixed gap + beyond the workspace frame. No interactive target is allowed to overlap another. - The speech bubble resizes directly from its edges and corners. Its frame stays inside a 24-point display inset and preserves a minimum right-side Miku rail; native directional cursors expose the otherwise unobtrusive resize targets. @@ -71,10 +77,10 @@ snapshot persist on close and restore on the next launch. ## Visual tokens The visual system uses a 4-point spacing base. Idle is transparent; active -surfaces use flat dark sky-blue tones, a translucent cyan rim, a restrained -teal shadow, and white terminal text. The header uses compact rounded app type -with small monospaced agent labels, making the surface read as a coding-agent -workspace rather than a generic terminal emulator. The app icon is a separate +surfaces use flat dark sky-blue tones, a translucent cyan rim with a slow opacity +pulse, a restrained teal shadow, and white terminal text. The header uses modern +SF app type with small monospaced agent labels, making the surface read as a +coding-agent workspace rather than a generic terminal emulator. The app icon is a separate terminal-and-Miku asset; the desktop companion contains only the 2D Miku character. Idle artwork stays centered and still above a subtle teal ground shadow. Its only idle motion is an intentional crossfade to an attentive gaze diff --git a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift index 4139f26..4fead50 100644 --- a/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift +++ b/Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift @@ -18,6 +18,24 @@ 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 .pi: Color(red: 0.740, green: 0.520, blue: 1.000) + case .openCode: Color(red: 0.400, green: 0.880, blue: 0.650) + } + } + + var symbol: String { + switch self { + case .codex: "circle.hexagongrid.fill" + case .claude: "sun.max.fill" + case .pi: "circle.grid.cross.fill" + case .openCode: "chevron.left.forwardslash.chevron.right" + } + } + var supportsApproval: Bool { self == .codex || self == .claude } @@ -37,6 +55,22 @@ enum AgentApproval: String, CaseIterable, Identifiable { case .auto: "Auto" } } + + var tint: Color { + switch self { + case .plan: Color(red: 0.640, green: 0.610, blue: 1.000) + case .accept: Color(red: 0.310, green: 0.820, blue: 1.000) + case .auto: Color(red: 1.000, green: 0.690, blue: 0.300) + } + } + + var symbol: String { + switch self { + case .plan: "map.fill" + case .accept: "checkmark.shield.fill" + case .auto: "bolt.fill" + } + } } struct AgentRunRequest: Equatable { diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index 2308315..d2c6d9d 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -11,187 +11,212 @@ struct AgentWorkspaceView: View { let onResizeEnded: () -> Void let rendererPolicy: TerminalRendererPolicy @FocusState private var isPromptFocused: Bool + @State private var isRimBreathing = false var body: some View { - VStack(spacing: 0) { - header - conversation - composer - if workspace.isTerminalPresented { - embeddedTerminal + 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)) + } + } + .background(AgentWorkspacePalette.surface) + .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) } - .background(AgentWorkspacePalette.surface) - .clipShape(SpeechBubbleShape()) - .overlay { SpeechBubbleShape().stroke(AgentWorkspacePalette.rim, lineWidth: 1) } - .overlay { - TerminalResizeHandles(onResize: onResize, onResizeEnded: onResizeEnded) + .onAppear { + isPromptFocused = true + withAnimation(.easeInOut(duration: 1.8).repeatForever(autoreverses: true)) { + isRimBreathing = true + } } - .shadow(color: .black.opacity(0.26), radius: 20, x: 0, y: 10) - .onAppear { isPromptFocused = true } .accessibilityElement(children: .contain) .accessibilityLabel("MikuCode coding agent workspace") } private var header: some View { HStack(spacing: 12) { - Text("miku-code") - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .foregroundStyle(.white) - Spacer() - Button { - withAnimation(.easeOut(duration: 0.16)) { + MikuCodeLogo(compact: true) + Spacer(minLength: 12) + WorkspaceStatus(isRunning: terminalSession.isRunning) + HeaderIconButton( + symbol: workspace.isTerminalPresented ? "rectangle.compress.vertical" : "rectangle.expand.vertical", + label: workspace.isTerminalPresented ? "Hide terminal" : "Show terminal", + tint: workspace.isTerminalPresented ? AgentWorkspacePalette.cyan : AgentWorkspacePalette.muted + ) { + withAnimation(.easeOut(duration: 0.18)) { workspace.isTerminalPresented.toggle() } - if workspace.isTerminalPresented { onTerminalFocus() } - } label: { - Image(systemName: "terminal") - .font(.system(size: 12, weight: .semibold)) - .frame(width: 28, height: 28) - } - .buttonStyle(.plain) - .foregroundStyle(workspace.isTerminalPresented ? AgentWorkspacePalette.teal : AgentWorkspacePalette.muted) - .background(AgentWorkspacePalette.control) - .clipShape(Circle()) - .accessibilityLabel(workspace.isTerminalPresented ? "Hide terminal" : "Show terminal") - - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 11, weight: .bold)) - .frame(width: 28, height: 28) + if workspace.isTerminalPresented { + onTerminalFocus() + } } - .buttonStyle(.plain) - .foregroundStyle(AgentWorkspacePalette.muted) - .background(AgentWorkspacePalette.control) - .clipShape(Circle()) - .accessibilityLabel("Return Miku to desktop") + HeaderIconButton( + symbol: "xmark", + label: "Return Miku to desktop", + tint: AgentWorkspacePalette.muted, + action: onClose + ) } - .padding(.leading, 20) - .padding(.trailing, 52) - .frame(height: 46) + .padding(.leading, 18) + .padding(.trailing, 58) + .frame(height: AgentWorkspaceLayout.headerHeight) .background(AgentWorkspacePalette.header) - .overlay(alignment: .bottom) { Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) } + .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: 16) { + VStack(alignment: .leading, spacing: 14) { if workspace.submittedPrompts.isEmpty, workspace.pendingPrompt == nil { - VStack(alignment: .leading, spacing: 8) { - Text(""" - M M III K K U U / CCCC OOO DDD EEEE - MM MM I K K U U / C O O D D E - M M M I KK U U / C O O D D EEE - M M I K K U U / C O O D D E - M M III K K UU / CCCC OOO DDD EEEE - """) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .foregroundStyle(AgentWorkspacePalette.teal) - .fixedSize(horizontal: true, vertical: false) - Text("local coding agent desktop") - .font(.system(size: 12, design: .rounded)) - .foregroundStyle(AgentWorkspacePalette.muted) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 44) + WorkspaceWelcome() + .padding(.top, 42) } else { ForEach(workspace.submittedPrompts) { prompt in - VStack(alignment: .leading, spacing: 8) { - Text("you · \(prompt.provider.title) · \(prompt.approval.title)") - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .foregroundStyle(AgentWorkspacePalette.teal) - Text(prompt.text) - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.white) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(AgentWorkspacePalette.message) - .clipShape(RoundedRectangle(cornerRadius: 12)) + AgentPromptCard(prompt: prompt, status: "sent") } if let prompt = workspace.pendingPrompt { - AgentPromptCard(prompt: prompt, status: "waiting for terminal capacity") + AgentPromptCard(prompt: prompt, status: "sending") } if !workspace.submittedPrompts.isEmpty { - HStack(spacing: 8) { - Circle().fill(AgentWorkspacePalette.teal).frame(width: 6, height: 6) - Text("Running in the integrated terminal") - .font(.system(size: 11, weight: .medium, design: .rounded)) - .foregroundStyle(AgentWorkspacePalette.muted) + HStack(spacing: 7) { + Circle() + .fill(AgentWorkspacePalette.cyan) + .frame(width: 6, height: 6) + Text("Running in the integrated terminal") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AgentWorkspacePalette.muted) } + .padding(.top, 2) } } if let error = workspace.submissionError { - Text(error) - .font(.system(size: 11, design: .rounded)) - .foregroundStyle(Color.red.opacity(0.92)) + Label(error, systemImage: "exclamationmark.circle") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AgentWorkspacePalette.error) } } - .frame(maxWidth: 620, alignment: .leading) + .frame(maxWidth: 680, alignment: .leading) .padding(.horizontal, 28) - .padding(.bottom, 28) + .padding(.bottom, 26) .frame(maxWidth: .infinity, alignment: .topLeading) } + .frame(maxHeight: .infinity) } - private var composer: some View { + private func composer(compact: Bool) -> some View { VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Menu { - ForEach(AgentProvider.allCases) { option in - Button(option.title) { workspace.provider = option } - .disabled(!option.supportsApproval) - } - } label: { - AgentWorkspacePill(title: workspace.provider.title) + if compact { + selectorRow + promptRow + } else { + HStack(alignment: .bottom, spacing: 12) { + selectorRow + .frame(width: 182, alignment: .leading) + promptRow } - .menuStyle(.borderlessButton) + } + } + .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) + } + .disabled(!workspace.isComposerInteractionEnabled) + } - Menu { - ForEach(AgentApproval.allCases) { option in - Button(option.title) { workspace.approval = option } - } - } label: { - AgentWorkspacePill(title: workspace.approval.title) + private var selectorRow: some View { + HStack(spacing: 8) { + Menu { + ForEach(AgentProvider.allCases) { option in + Button(option.title) { workspace.provider = option } } - .menuStyle(.borderlessButton) - Spacer() + } label: { + AgentChoicePill( + title: workspace.provider.title, + tint: workspace.provider.tint, + symbol: workspace.provider.symbol + ) } + .menuStyle(.borderlessButton) - HStack(alignment: .bottom, spacing: 10) { - TextField("Ask the coding agent…", text: $workspace.prompt, axis: .vertical) - .textFieldStyle(.plain) - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.white) - .focused($isPromptFocused) - .lineLimit(1...5) - .onSubmit(submit) + Menu { + ForEach(AgentApproval.allCases) { option in + Button(option.title) { workspace.approval = option } + } + } label: { + AgentChoicePill( + title: workspace.approval.title, + tint: workspace.approval.tint, + symbol: workspace.approval.symbol + ) + } + .menuStyle(.borderlessButton) + } + } - Button(action: submit) { + private var promptRow: some View { + HStack(alignment: .bottom, spacing: 10) { + TextField("Describe what to build…", text: $workspace.prompt, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(.white) + .focused($isPromptFocused) + .lineLimit(1...5) + .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") - .font(.system(size: 12, weight: .bold)) - .frame(width: 32, height: 32) } - .buttonStyle(.plain) - .foregroundStyle(.black) - .background(AgentWorkspacePalette.teal) - .clipShape(Circle()) - .disabled(!canSubmit) - .opacity(canSubmit ? 1 : 0.38) - .accessibilityLabel("Start coding session") + .font(.system(size: 12, weight: .bold)) + .frame(minWidth: 74, minHeight: 40) } - .padding(12) - .background(AgentWorkspacePalette.composer) - .overlay { RoundedRectangle(cornerRadius: 12).stroke(AgentWorkspacePalette.rim, lineWidth: 1) } - .clipShape(RoundedRectangle(cornerRadius: 12)) + .buttonStyle(WorkspaceSendButtonStyle(enabled: canSubmit)) + .disabled(!canSubmit) + .accessibilityLabel("Start coding session") } - .padding(20) - .background(AgentWorkspacePalette.header) - .overlay(alignment: .top) { Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) } - .disabled(!workspace.isComposerInteractionEnabled) } - private var embeddedTerminal: some View { + private func embeddedTerminal(height: CGFloat) -> some View { TerminalPanel( session: terminalSession, focusRequestID: terminalFocusRequestID, @@ -203,9 +228,9 @@ struct AgentWorkspaceView: View { showsChrome: false, showsResizeHandles: false ) - .frame(height: 260) - .background(Color.black.opacity(0.34)) - .overlay(alignment: .top) { Rectangle().fill(AgentWorkspacePalette.rim).frame(height: 1) } + .frame(height: height) + .background(AgentWorkspacePalette.terminal) + .overlay(alignment: .top) { Rectangle().fill(AgentWorkspacePalette.hairline).frame(height: 1) } .transition(.move(edge: .bottom).combined(with: .opacity)) .onTapGesture(perform: onTerminalFocus) } @@ -224,51 +249,196 @@ struct AgentWorkspaceView: View { } } +private enum AgentWorkspaceLayout { + static let headerHeight: CGFloat = 52 + static let compactComposerWidth: CGFloat = 620 + + static func terminalHeight(for workspaceHeight: CGFloat) -> CGFloat { + min(280, max(116, workspaceHeight * 0.34)) + } +} + +private struct WorkspaceWelcome: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + MikuCodeLogo(compact: false) + Text("A local workspace for focused coding sessions.") + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AgentWorkspacePalette.muted) + Text("Choose an agent, set its approval mode, then describe the work.") + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(AgentWorkspacePalette.subtle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct MikuCodeLogo: View { + let compact: Bool + + var body: some View { + HStack(spacing: compact ? 8 : 10) { + ZStack { + RoundedRectangle(cornerRadius: compact ? 7 : 9) + .fill(AgentWorkspacePalette.logoBackground) + Image(systemName: "sparkle") + .font(.system(size: compact ? 10 : 14, weight: .bold)) + .foregroundStyle(AgentWorkspacePalette.cyan) + } + .frame(width: compact ? 26 : 34, height: compact ? 26 : 34) + + VStack(alignment: .leading, spacing: compact ? 0 : 2) { + Text("MIKU CODE") + .font(.system(size: compact ? 11 : 17, weight: .bold, design: .default)) + .tracking(compact ? 1.2 : 1.8) + .foregroundStyle(.white) + if !compact { + Text("CODING AGENT DESKTOP") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .tracking(0.7) + .foregroundStyle(AgentWorkspacePalette.subtle) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Miku Code") + } +} + +private struct WorkspaceStatus: View { + let isRunning: Bool + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(isRunning ? AgentWorkspacePalette.live : AgentWorkspacePalette.subtle) + .frame(width: 6, height: 6) + Text(isRunning ? "LOCAL SHELL READY" : "STARTING") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .tracking(0.4) + .foregroundStyle(AgentWorkspacePalette.subtle) + } + .accessibilityLabel(isRunning ? "Local shell ready" : "Local shell starting") + } +} + +private struct HeaderIconButton: View { + let symbol: String + let label: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(tint) + .frame(width: 30, height: 30) + .background(AgentWorkspacePalette.control) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } +} + private struct AgentPromptCard: View { let prompt: AgentPrompt let status: String var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("you · \(prompt.provider.title) · \(status)") - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - .foregroundStyle(AgentWorkspacePalette.teal) + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 7) { + AgentTag(title: prompt.provider.title, tint: prompt.provider.tint) + AgentTag(title: prompt.approval.title, tint: prompt.approval.tint) + Text(status.uppercased()) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(AgentWorkspacePalette.subtle) + } Text(prompt.text) - .font(.system(size: 14, design: .rounded)) + .font(.system(size: 14, weight: .regular)) .foregroundStyle(.white) } - .padding(16) + .padding(15) .frame(maxWidth: .infinity, alignment: .leading) .background(AgentWorkspacePalette.message) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(alignment: .leading) { + Rectangle().fill(prompt.provider.tint).frame(width: 2) + } + .clipShape(RoundedRectangle(cornerRadius: 14)) } } -private struct AgentWorkspacePill: View { +private struct AgentTag: View { let title: String + let tint: Color var body: some View { - HStack(spacing: 5) { + Text(title.uppercased()) + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(tint.opacity(0.14)) + .clipShape(Capsule()) + } +} + +private struct AgentChoicePill: View { + let title: String + let tint: Color + let symbol: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: symbol) + .font(.system(size: 9, weight: .bold)) Text(title) Image(systemName: "chevron.down") .font(.system(size: 8, weight: .bold)) + .foregroundStyle(AgentWorkspacePalette.subtle) } - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .foregroundStyle(AgentWorkspacePalette.muted) - .padding(.horizontal, 9) - .frame(height: 26) - .background(AgentWorkspacePalette.control) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .frame(height: 30) + .background(tint.opacity(0.14)) + .overlay { Capsule().stroke(tint.opacity(0.28), lineWidth: 1) } .clipShape(Capsule()) } } +private struct WorkspaceSendButtonStyle: ButtonStyle { + let enabled: 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) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + private enum AgentWorkspacePalette { - static let surface = Color(red: 0.026, green: 0.073, blue: 0.122) - static let header = Color(red: 0.040, green: 0.105, blue: 0.166) - static let composer = Color(red: 0.055, green: 0.130, blue: 0.196) - static let message = Color(red: 0.100, green: 0.255, blue: 0.350).opacity(0.18) - static let control = Color(red: 0.310, green: 0.700, blue: 0.890).opacity(0.16) - static let rim = Color(red: 0.340, green: 0.760, blue: 0.960).opacity(0.28) - static let muted = Color(red: 0.710, green: 0.840, blue: 0.940).opacity(0.70) - static let teal = Color(red: 0.390, green: 0.820, blue: 0.990) + 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) + 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 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/Companion/AppPresentation.swift b/Sources/MikuCodeApp/Companion/AppPresentation.swift index 9f81a72..a52a333 100644 --- a/Sources/MikuCodeApp/Companion/AppPresentation.swift +++ b/Sources/MikuCodeApp/Companion/AppPresentation.swift @@ -185,7 +185,7 @@ enum TerminalBubbleLayout { let inset = MikuVisualTokens.space6 let width = max(1, size.width) let height = max(1, size.height) - let minimumMikuRail = max(120, width * 0.10) + let minimumMikuRail = max(120, width * 0.10) + MikuVisualTokens.mikuWorkspaceGap let frame = CGRect( x: inset, y: inset, @@ -266,7 +266,7 @@ enum PresentationLayout { let width = max(1, size.width) let height = max(1, size.height) let mikuMaxX = width - MikuVisualTokens.space4 - let mikuMinX = terminalFrame.maxX - MikuVisualTokens.space4 + let mikuMinX = min(mikuMaxX - 1, terminalFrame.maxX + MikuVisualTokens.mikuWorkspaceGap) let mikuHeight = terminalFrame.height * 0.92 return CGRect( x: mikuMinX, @@ -292,6 +292,7 @@ enum MikuVisualTokens { static let idleBreathingDuration = 3.6 static let pressDuration = 0.12 static let terminalResizeHitSize: CGFloat = 12 + static let mikuWorkspaceGap: CGFloat = 18 static let terminalFontSize: CGFloat = 13 static let terminalPaddingX: CGFloat = 16 static let terminalPaddingY: CGFloat = 12 diff --git a/Sources/MikuCodeApp/Companion/MikuCompanion.swift b/Sources/MikuCodeApp/Companion/MikuCompanion.swift index 5125ee1..e5c7995 100644 --- a/Sources/MikuCodeApp/Companion/MikuCompanion.swift +++ b/Sources/MikuCodeApp/Companion/MikuCompanion.swift @@ -11,36 +11,34 @@ struct MikuEntryButton: View { var body: some View { ZStack(alignment: .top) { Button(action: openTerminal) { - MikuCompanion(scale: .idle, looksAtUser: isHovering) - .frame(maxWidth: .infinity, maxHeight: .infinity) + ZStack(alignment: .top) { + MikuCompanion(scale: .idle, looksAtUser: isHovering) + .frame(maxWidth: .infinity, maxHeight: .infinity) + if isHovering { + Image(systemName: "terminal") + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundStyle(MikuVisualTokens.terminalText) + .frame(width: 58, height: 42) + .background(MiniTerminalBubbleShape().fill(MikuVisualTokens.bubbleTop)) + .overlay { + MiniTerminalBubbleShape().stroke( + MikuVisualTokens.mikuTeal.opacity(0.95), + lineWidth: 1.5 + ) + } + .offset(y: 2) + .transition( + reduceMotion + ? .identity + : .move(edge: .bottom).combined(with: .opacity) + ) + .accessibilityHidden(true) + } + } } .buttonStyle(.plain) .contentShape(Rectangle()) .accessibilityLabel("Open MikuCode terminal") - - if isHovering { - Button(action: openTerminal) { - Image(systemName: "terminal") - .font(.system(size: 15, weight: .semibold, design: .monospaced)) - .foregroundStyle(MikuVisualTokens.terminalText) - .frame(width: 58, height: 42) - .background(MiniTerminalBubbleShape().fill(MikuVisualTokens.bubbleTop)) - .overlay { - MiniTerminalBubbleShape().stroke( - MikuVisualTokens.mikuTeal.opacity(0.95), - lineWidth: 1.5 - ) - } - } - .buttonStyle(.plain) - .offset(y: 2) - .transition( - reduceMotion - ? .identity - : .move(edge: .bottom).combined(with: .opacity) - ) - .accessibilityLabel("Open terminal") - } } .padding(MikuVisualTokens.space2) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -82,9 +80,6 @@ struct MikuCompanion: View { case active } - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var isFloating = false - @State private var isBreathing = false let scale: Scale let looksAtUser: Bool @@ -117,41 +112,17 @@ struct MikuCompanion: View { .interpolation(.high) .scaledToFit() .scaleEffect(scale == .idle ? 1.16 : 1, anchor: .bottom) - .scaleEffect(scale == .idle && isBreathing ? 1.006 : 1, anchor: .bottom) .scaleEffect(looksAtUser ? 1.035 : 1, anchor: .bottom) .saturation(scale == .idle ? 0.98 : 1) .brightness(looksAtUser ? 0.025 : 0) - .offset(y: floatingOffset) - .rotationEffect(.degrees(scale == .idle && isBreathing ? 0.3 : -0.3)) .shadow( color: MikuVisualTokens.mikuTeal.opacity(scale == .idle ? 0.14 : 0.10), radius: scale == .idle ? 12 : 18 ) .animation( - reduceMotion ? nil : .easeInOut(duration: 0.18), + .easeInOut(duration: 0.18), value: looksAtUser ) - .onAppear { updateMotion() } - .onChange(of: reduceMotion) { _, _ in updateMotion() } - } - - private var floatingOffset: CGFloat { - guard scale == .idle, !reduceMotion else { return 0 } - return isFloating ? -2 : 1 - } - - private func updateMotion() { - guard scale == .idle, !reduceMotion else { - isFloating = false - isBreathing = false - return - } - withAnimation(.easeInOut(duration: MikuVisualTokens.idleFloatDuration).repeatForever(autoreverses: true)) { - isFloating = true - } - withAnimation(.easeInOut(duration: MikuVisualTokens.idleBreathingDuration).repeatForever(autoreverses: true)) { - isBreathing = true - } } } diff --git a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift index 121ec3f..ee123c2 100644 --- a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift +++ b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift @@ -286,7 +286,9 @@ final class OverlayHitTestView: NSView { } override func hitTest(_ point: NSPoint) -> NSView? { - if idlePassthroughEnabled, !interactiveRegion.contains(point) { + let fallbackRegion = PresentationLayout.metrics(in: bounds.size).idleEntryFrame + let activeRegion = interactiveRegion.isEmpty ? fallbackRegion : interactiveRegion + if idlePassthroughEnabled, !activeRegion.contains(point) { return nil } return super.hitTest(point) diff --git a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift index 89dfc18..052328f 100644 --- a/Tests/MikuCodeAppTests/AppShellPolicyTests.swift +++ b/Tests/MikuCodeAppTests/AppShellPolicyTests.swift @@ -163,9 +163,40 @@ final class AppShellPolicyTests: XCTestCase { )) coordinator.panel.sendEvent(down) + XCTAssertFalse(coordinator.panel.isTerminalPresented) coordinator.panel.sendEvent(up) + XCTAssertTrue(coordinator.panel.isTerminalPresented) XCTAssertEqual(coordinator.panel.level, .normal) + coordinator.completeOpening() + XCTAssertTrue(session.isRunning) + } + + func testClickingOutsideIdleMikuRegionDoesNotOpenThePanel() throws { + let coordinator = MikuPanelCoordinator( + screenFrame: CGRect(x: 0, y: 0, width: 1_280, height: 800), + terminalSession: TerminalSessionModel( + persistence: TerminalSessionPersistence(url: nil), + shell: "/bin/sh" + ), + ordersFront: false + ) + let event = try XCTUnwrap(NSEvent.mouseEvent( + with: .leftMouseUp, + location: CGPoint(x: 80, y: 80), + modifierFlags: [], + timestamp: 0, + windowNumber: coordinator.panel.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + )) + + coordinator.panel.sendEvent(event) + + XCTAssertFalse(coordinator.panel.isTerminalPresented) + XCTAssertTrue(coordinator.panel.styleMask.contains(.nonactivatingPanel)) } func testAgentCoordinatorDoesNotInstallHiddenWorkspaceShortcuts() { diff --git a/Tests/MikuCodeAppTests/MikuCodeAppTests.swift b/Tests/MikuCodeAppTests/MikuCodeAppTests.swift index 395f626..ed593c3 100644 --- a/Tests/MikuCodeAppTests/MikuCodeAppTests.swift +++ b/Tests/MikuCodeAppTests/MikuCodeAppTests.swift @@ -156,6 +156,14 @@ final class PresentationLayoutTests: XCTestCase { metrics.mikuFrame.height, metrics.terminalFrame.height * 0.88 ) + XCTAssertTrue( + metrics.terminalFrame.intersection(metrics.mikuFrame).isNull, + "The Miku close target must never cover the coding workspace." + ) + XCTAssertGreaterThanOrEqual( + metrics.mikuFrame.minX - metrics.terminalFrame.maxX, + MikuVisualTokens.mikuWorkspaceGap + ) } } diff --git a/Tests/MikuCodeAppTests/PTYProcessTests.swift b/Tests/MikuCodeAppTests/PTYProcessTests.swift index 256ae29..792b7a3 100644 --- a/Tests/MikuCodeAppTests/PTYProcessTests.swift +++ b/Tests/MikuCodeAppTests/PTYProcessTests.swift @@ -176,7 +176,7 @@ final class PTYProcessTests: XCTestCase { try process.write(Data(command.utf8)) wait(for: [ready], timeout: 5) try process.write(framedPaste) - wait(for: [received, exited], timeout: 30) + wait(for: [received, exited], timeout: 60) XCTAssertEqual(capture.matchCount, 1) }