Skip to content
14 changes: 10 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ responsibility rather than by framework or build order.
```text
Application/ App lifecycle and display selection
Companion/ Idle/terminal presentation state and transparent panel
Agent/ Coding-agent request construction and delivery state
Terminal/
Core/ ANSI parsing, buffer, paging, input encoding, value types
PTY/ Pseudoterminal process and non-blocking I/O boundary
Expand All @@ -19,14 +20,19 @@ Assets/ Processed application and companion artwork
Dependency direction is intentionally one-way:

```text
Application + Companion -> Terminal Rendering -> Terminal Workspace/Session -> PTY + Core
Application + Companion -> Agent + Terminal Rendering -> Terminal Workspace/Session -> PTY + Core
```

`TerminalWorkspaceModel` is the only owner of tab/split topology. Every leaf
maps to exactly one `TerminalSessionModel` and one `TerminalSessionID`-derived
persistence file; views route focus and raw input to that leaf only. It never
`MikuPanelCoordinator` owns one `TerminalSessionModel` for the one visible
embedded PTY. Views route focus and raw input to that session only; it never
creates another `NSWindow`.

`AgentWorkspaceModel` is the UI-side owner of a coding-agent request. It
constructs only supported provider/approval commands, hands them to the active
`TerminalSessionModel`, and records the request in conversation history only
after the PTY reports delivery for the matching request token. It does not own
or create a second PTY, panel, or window.

`Terminal/Core` contains deterministic terminal behavior and must not import
SwiftUI, AppKit, Metal, or process-launch concerns. UI and process boundaries
belong to their named directories; this keeps parser and buffer changes
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Contributing to MikuCode

Thanks for helping improve MikuCode. Contributions should make the macOS single-session terminal and companion UI clearer, more reliable, or more accessible while keeping its focused scope.
Thanks for helping improve MikuCode. Contributions should make the macOS
coding-agent workspace, local embedded terminal, or companion UI clearer,
more reliable, or more accessible while keeping its focused one-panel scope.

This private repository requires a GitHub-authorized account for cloning and contributing.

Expand Down
48 changes: 23 additions & 25 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ the Dock. Development `swift run` output is not part of the product UI.
| --- | --- | --- |
| `idle` | Transparent panel; a centered front-facing chibi Miku stays still, changes to attentive eye contact on hover, and reveals a terminal bubble | Stopped |
| `opening` | Same panel transitions to the active composition | Stopped |
| `terminal` | Screen-sized, borderless, normal-level panel with a large speech-bubble local-terminal workspace, compact MikuCode header, and Miku right rail | Each visible leaf is running and the focused pane is directly focused |
| `closing` | Active composition leaves the same panel | PTYs stopped, drained, and persisted |
| `terminal` | Screen-sized, borderless, normal-level panel with a large dark sky-blue speech-bubble coding-agent workspace, compact MikuCode header, and Miku right rail | The coordinator-owned local PTY runs whether its embedded view is shown or hidden |
| `closing` | Active composition leaves the same panel | The local PTY stops, drains, and persists |

Repeated or out-of-order events are no-ops. Command-W and panel-close requests
use the close transition and return to `idle`.
Expand All @@ -33,23 +33,22 @@ use the close transition and return to `idle`.
reveals a small speech-bubble terminal affordance. The companion never walks,
slides, or bobs in idle; only the intentional gaze state changes.
Clicking either Miku herself or that bubble begins the terminal transition.
- In terminal state, the bubble header contains `miku-code`, a horizontally
scrollable selected-tab strip, the actual focused-PTY state, and one close
control. `Command-T` creates a tab, `Command-D` splits the focused pane to
the right, and `Shift-Command-D` splits it below; these actions retain the
one-panel, one-bubble composition. Hovering expanded Miku reveals a close
- In terminal state, the bubble opens as a coding-agent workspace: the centered
MikuCode mark gives way to prompt history, a provider/approval composer, and
a terminal toggle. Codex and Claude support `Plan`, `Accept`, and `Auto`;
the workspace only records a request after its token-matched PTY delivery.
The terminal toggle reveals that same local PTY inside the bubble—never a
second window. Hovering expanded Miku reveals a close
marker. Clicking either close
target stops the terminal and returns the same running app to its small idle
companion at the floating layer; it only returns behind other apps after the
app itself loses focus, and never quits MikuCode.
- The terminal header is an agent-workspace cue, not a toolbar: it contains the
app name, tab navigation, the actual focused-PTY state, and one close
control. There are no traffic
lights, provider selectors, paging/search controls, or permanent Clear,
Interrupt, or Send buttons. Command-F opens the only transient text field: a
compact in-bubble terminal finder. Command-G and Shift-Command-G navigate
its case-insensitive matches across output pages; Escape restores terminal
focus and removes the finder.
- The workspace header is an agent-workspace cue, not a toolbar: it contains
the app name, an embedded-terminal toggle, and one close control. The
composer is the only persistent input surface. Command-F opens the only
transient terminal text field: a compact in-bubble finder. Command-G and
Shift-Command-G navigate its case-insensitive matches across output pages;
Escape restores terminal focus and removes the finder.
- The speech bubble resizes directly from its edges and corners. Its frame stays
inside a 24-point display inset and preserves a minimum right-side Miku rail;
native directional cursors expose the otherwise unobtrusive resize targets.
Expand All @@ -66,21 +65,20 @@ event monitor is used.

PTY output is read without blocking, input is bounded and drained through a
write source, resize is sent through `TIOCSWINSZ`, and shutdown sends `SIGHUP`
with a bounded `SIGKILL` fallback. Each workspace leaf owns its own PTY and
bounded snapshot; the tab/split/focus layout and per-session snapshots persist
on close and restore on the next launch.
with a bounded `SIGKILL` fallback. The coordinator-owned PTY and its bounded
snapshot persist on close and restore on the next launch.

## Visual tokens

The visual system uses a 4-point spacing base. Idle is transparent; active
surfaces use a graphite two-stop bubble, a translucent rim, a restrained teal
shadow, and white terminal text. The header uses compact rounded app type with
monospaced `agent terminal` and PTY state labels, making the surface read as a
coding-agent workspace rather than a generic terminal emulator. The app icon is a separate
surfaces use flat dark sky-blue tones, a translucent cyan rim, a restrained
teal shadow, and white terminal text. The header uses compact rounded app type
with small monospaced agent labels, making the surface read as a coding-agent
workspace rather than a generic terminal emulator. The app icon is a separate
terminal-and-Miku asset; the desktop companion contains only the 2D Miku
character. Idle artwork stays centered and still above a subtle teal ground
shadow. Its only idle motion is an intentional crossfade to an attentive gaze
on hover; opening, active terminal, and closing states use the focus frame as
the deliberate zoom transition. ANSI colors
are confined to terminal output. Reduce Motion removes companion float and
state transitions while preserving state changes.
the deliberate zoom transition. ANSI colors are confined to terminal output.
Reduce Motion removes companion float and state transitions while preserving
state changes.
38 changes: 21 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
# MikuCode

MikuCode is a macOS menu-bar-style companion that becomes a focused local PTY
terminal in place. One transparent, persistent `NSPanel` owns both states:
the idle panel shows Miku on the display under the pointer, and the active panel
fills the display with a speech-bubble terminal and Miku on the right.
MikuCode is a macOS coding-agent desktop with an integrated local PTY. One
transparent, persistent `NSPanel` owns both states: the idle panel shows Miku
on the display under the pointer, and the active panel fills the display with a
dark sky-blue speech-bubble workspace and Miku on the right.

## Behavior

- The PTY output view is selectable and receives keyboard input directly.
- Miku stays still and front-facing in her idle area. Hovering crossfades to an
attentive gaze and reveals a small terminal speech bubble; selecting either
Miku or that bubble opens the full terminal in the same panel.
- The expanded terminal uses the default MikuCode theme with a compact
`miku-code` header, a horizontally scrollable tab strip, and one close
button. `Command-T` opens a new local shell tab; `Command-D` splits the
focused terminal to the right; `Shift-Command-D` splits it below. Each leaf
owns an independent PTY, transcript, current directory, and persisted
session snapshot. Selecting the expanded Miku or the
close button returns the same running app to its small idle Miku without
- The expanded workspace uses the default MikuCode theme with a compact
`miku-code` header, one embedded-terminal toggle, and one close button. The
coordinator owns one local PTY, transcript, current directory, and persisted
session snapshot. Selecting the expanded Miku or the close button returns
the same running app to its small idle Miku without
sending it behind other windows; it returns to the desktop layer only after
the app loses focus, and never quits MikuCode.
- The agent workspace starts with a centered MikuCode mark and a focused
prompt composer. Choose Codex or Claude plus `Plan`, `Accept`, or `Auto`;
the request is written to the integrated PTY only after the local terminal
can accept it. The terminal button expands or hides that same embedded PTY
below the conversation—no second window is created.
- Printable keys, control sequences, paste, resize, paging, find, clear, and
interrupt use terminal keyboard actions. Command-F opens a compact transient
finder inside the terminal bubble; Command-G and Shift-Command-G move between
Expand All @@ -44,9 +47,9 @@ in [ARCHITECTURE.md](ARCHITECTURE.md).

- `MikuPanelCoordinator` retains the single panel and applies the presentation
state machine.
- `TerminalWorkspaceModel` owns tabs, focused-pane splits, per-session
storage, and workspace restoration while retaining one outer speech bubble.
- `TerminalPanel` embeds an individual terminal pane.
- `AgentWorkspaceModel` owns provider/approval selection and only records an
agent prompt after its PTY write is confirmed.
- `TerminalPanel` embeds the coordinator-owned terminal pane.
- `TerminalSessionModel` owns one PTY lifecycle, buffering, persistence, and
terminal updates.
- `PTYProcess` uses `forkpty`, a bounded non-blocking output/input path, and
Expand All @@ -73,9 +76,10 @@ Requirements: macOS 14 or later, Swift 6 or later, and a local POSIX shell.

## Scope

MikuCode provides local shell tabs and focused-pane splits inside one panel.
Remote connections, profiles, SSH, editor features, tab closing/reordering,
and complete terminal compatibility are out of scope. The protocol
MikuCode provides one local shell and coding-agent prompts inside one panel.
Remote connections, profiles, SSH, editor features, terminal tabs/splits,
provider-specific session restoration, and complete terminal compatibility are
out of scope. The protocol
intentionally covers a focused subset of UTF-8, ANSI SGR, cursor movement,
erasure, and bracketed paste behavior.

Expand Down
186 changes: 186 additions & 0 deletions Sources/MikuCodeApp/Agent/AgentWorkspaceModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import Foundation
import SwiftUI

enum AgentProvider: String, CaseIterable, Identifiable {
case codex
case claude
case pi
case openCode

var id: Self { self }

var title: String {
switch self {
case .codex: "Codex"
case .claude: "Claude"
case .pi: "Pi"
case .openCode: "OpenCode"
}
}

var supportsApproval: Bool {
self == .codex || self == .claude
}
}

enum AgentApproval: String, CaseIterable, Identifiable {
case plan
case accept
case auto

var id: Self { self }

var title: String {
switch self {
case .plan: "Plan"
case .accept: "Accept"
case .auto: "Auto"
}
}
}

struct AgentRunRequest: Equatable {
let provider: AgentProvider
let approval: AgentApproval
let prompt: String

init?(provider: AgentProvider, approval: AgentApproval, prompt: String) {
guard provider.supportsApproval else { return nil }
guard !prompt.isEmpty else { return nil }
guard prompt.unicodeScalars.allSatisfy({ scalar in
scalar.value >= 0x20 && scalar.value != 0x7F
}) else {
return nil
}
self.provider = provider
self.approval = approval
self.prompt = prompt
}

var shellCommand: String {
switch provider {
case .codex:
let sandbox = switch approval {
case .plan: "read-only"
case .accept: "workspace-write"
case .auto: "danger-full-access"
}
return "codex exec --sandbox \(sandbox) \(Self.quote(prompt))"
case .claude:
let permissionMode = switch approval {
case .plan: "plan"
case .accept: "acceptEdits"
case .auto: "bypassPermissions"
}
return "claude --permission-mode \(permissionMode) \(Self.quote(prompt))"
case .pi, .openCode:
preconditionFailure("Unsupported provider cannot create a run request.")
}
}

private static func quote(_ value: String) -> String {
"'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'"
}
}

@MainActor
struct LocalPTYAgentRunner {
let terminalSession: TerminalSessionModel

func start(_ request: AgentRunRequest) -> TerminalAgentInputSubmission? {
guard terminalSession.isRunning else { return nil }
return terminalSession.submitAgentCommand(request.shellCommand)
}
}

@MainActor
final class AgentWorkspaceModel: ObservableObject {
typealias RequestStarter = (TerminalSessionModel, AgentRunRequest) -> TerminalAgentInputSubmission?

@Published var provider = AgentProvider.codex
@Published var approval = AgentApproval.accept
@Published var prompt = ""
@Published private(set) var submittedPrompts: [AgentPrompt] = []
@Published private(set) var pendingPrompt: AgentPrompt?
private(set) var pendingPromptDeliveryToken: UUID?
@Published var isTerminalPresented = false
@Published private(set) var submissionError: String?
private let startRequest: RequestStarter

init(startRequest: @escaping RequestStarter = { terminalSession, request in
LocalPTYAgentRunner(terminalSession: terminalSession).start(request)
}) {
self.startRequest = startRequest
}

func submit(to terminalSession: TerminalSessionModel) {
let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard let request = AgentRunRequest(provider: provider, approval: approval, prompt: text) else {
submissionError = provider.supportsApproval
? "Prompt contains unsupported terminal control characters."
: "This provider is not available in the current workspace."
return
}
terminalSession.onInputDeliveryUpdate = { [weak self] update in
self?.receiveInputDelivery(update)
}
let agentPrompt = AgentPrompt(provider: provider, approval: approval, text: text)
guard let submission = startRequest(terminalSession, request) else {
submissionError = terminalSession.isRunning
? "The local terminal could not accept this prompt."
: "The local terminal is still opening."
return
}
switch submission.delivery {
case .delivered:
submittedPrompts.append(agentPrompt)
case .queued:
pendingPrompt = agentPrompt
pendingPromptDeliveryToken = submission.deliveryToken
}
prompt = ""
submissionError = nil
isTerminalPresented = true
}

var isAwaitingPromptDelivery: Bool {
pendingPrompt != nil
}

var isComposerInteractionEnabled: Bool {
pendingPrompt == nil
}

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

private func failPendingPrompt(_ pendingPrompt: AgentPrompt) {
self.pendingPrompt = nil
pendingPromptDeliveryToken = nil
provider = pendingPrompt.provider
approval = pendingPrompt.approval
prompt = pendingPrompt.text
submissionError = "The queued prompt was not delivered."
}
}

struct AgentPrompt: Identifiable {
let id = UUID()
let provider: AgentProvider
let approval: AgentApproval
let text: String
}
Loading