Skip to content

Commit 0580b97

Browse files
committed
fix: refine interactive Miku terminal
1 parent 031b7ba commit 0580b97

10 files changed

Lines changed: 188 additions & 58 deletions

File tree

AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# MikuCode Engineering Rules
2+
3+
## Required checks before every push
4+
5+
Run the CI-equivalent commands and do not push a failing result. First ensure
6+
the active developer directory is a full Xcode installation; this workstation's
7+
default Command Line Tools directory cannot compile XCTest targets:
8+
9+
```sh
10+
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
11+
swift build -Xswiftc -warnings-as-errors
12+
swift test -Xswiftc -warnings-as-errors
13+
```
14+
15+
The GitHub workflow currently selects Xcode 16.4. Treat Swift concurrency
16+
diagnostics from that toolchain as release-blocking: shared AppKit objects must
17+
be isolated to `@MainActor` or created on demand, never stored in unisolated
18+
static mutable state.
19+
20+
For changes to the companion or terminal presentation, also launch
21+
`swift run MikuCodeApp` and manually verify idle movement, hover focus, open,
22+
terminal input, and close-to-idle. After a push, inspect the matching GitHub
23+
Actions run before reporting CI as healthy.
24+
25+
Keep the app as one panel and one local PTY terminal. Do not add a preview
26+
desktop window, external-terminal launch path, or permanent demo controls.

DESIGN.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,27 @@ space.
1111
| --- | --- | --- |
1212
| `idle` | Transparent panel; a smooth 2D virtual-streamer Miku walks slowly along the lower-right wallpaper area and reveals a terminal bubble on hover | Stopped |
1313
| `opening` | Same panel transitions to the active composition | Stopped |
14-
| `terminal` | Screen-sized, borderless, normal-level panel with a large speech-bubble PTY terminal and Miku right rail | Running and directly focused |
14+
| `terminal` | Screen-sized, borderless, normal-level panel with a large speech-bubble PTY terminal, compact MikuCode header, and Miku right rail | Running and directly focused |
1515
| `closing` | Active composition leaves the same panel | Stopped, drained, and persisted |
1616

1717
Repeated or out-of-order events are no-ops. Command-W and panel-close requests
1818
use the close transition and return to `idle`.
1919

2020
## Composition
2121

22-
- The active terminal occupies 74% of display width and 84% of display height;
22+
- The active terminal occupies 77% of display width and 84% of display height;
2323
the Miku rail is right-aligned and occupies the remaining region.
2424
- The idle panel is fully transparent, shadowless, and passes pointer events
25-
through outside its documented 200 × 340 point lower-right Miku region.
25+
through outside its documented 220 × 360 point lower-right Miku region.
2626
- Hover shifts Miku's focus toward the user and reveals a small speech-bubble
2727
terminal affordance. Her artwork switches to a direct, attentive gaze.
2828
Clicking either Miku herself or that bubble begins the terminal transition.
29-
- The terminal is the only visible active control. It has no title bar,
30-
traffic lights, toolbar, status chips, text fields, paging/search controls,
31-
or permanent Clear, Interrupt, or Send buttons.
29+
- In terminal state, the bubble header contains `miku-code` and one close
30+
control. Clicking it or expanded Miku closes the terminal and returns to idle.
31+
- The terminal header is an agent-workspace cue, not a toolbar: it contains the
32+
app name, the actual PTY state, and one close control. There are no traffic
33+
lights, provider selectors, text fields, paging/search controls, or permanent
34+
Clear, Interrupt, or Send buttons.
3235
- Terminal output remains selectable through AppKit accessibility text even
3336
when Metal renders the visible glyphs.
3437

@@ -48,10 +51,12 @@ on terminal exit.
4851
## Visual tokens
4952

5053
The visual system uses a 4-point spacing base. Idle is transparent; active
51-
surfaces use a blue-black two-stop bubble, a translucent rim, a restrained
52-
teal shadow, and white terminal text. The app icon is a separate
54+
surfaces use a graphite two-stop bubble, a translucent rim, a restrained teal
55+
shadow, and white terminal text. The header uses compact rounded app type with
56+
monospaced `agent terminal` and PTY state labels, making the surface read as a
57+
coding-agent workspace rather than a generic terminal emulator. The app icon is a separate
5358
terminal-and-Miku asset; the desktop companion contains only the 2D Miku
5459
character. Idle artwork crossfades between left and right walking frames while
55-
moving horizontally; hover and terminal states use the focus frame. ANSI colors
60+
moving horizontally above a subtle teal ground shadow; hover and terminal states use the focus frame. ANSI colors
5661
are confined to terminal output. Reduce Motion removes companion float and
5762
state transitions while preserving state changes.

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ fills the display with a speech-bubble terminal and Miku on the right.
88
## Behavior
99

1010
- The PTY output view is selectable and receives keyboard input directly.
11-
- Hovering Miku shifts her attention toward the user and reveals the only idle
12-
control: a small terminal speech bubble. Selecting that bubble opens the
13-
full terminal in the same panel.
11+
- Miku walks slowly across her idle area. Hovering shifts her attention toward
12+
the user and reveals a small terminal speech bubble; selecting either Miku
13+
or that bubble opens the full terminal in the same panel.
14+
- The expanded terminal uses the default MikuCode theme with a compact
15+
`miku-code` header and close button. Selecting the expanded Miku or the
16+
close button returns to idle.
1417
- Printable keys, control sequences, paste, resize, paging, find, clear, and
1518
interrupt use terminal keyboard or native text-view actions; there are no
1619
permanent toolbars, text fields, buttons, status chips, or demo controls.

Sources/MikuCodeApp/AppPresentation.swift

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ enum PresentationLayout {
145145
let width = max(1, size.width)
146146
let height = max(1, size.height)
147147
let activeInset = min(32, max(16, width * 0.03))
148-
let terminalSize = CGSize(width: width * 0.74, height: height * 0.84)
148+
let terminalSize = CGSize(width: width * 0.77, height: height * 0.84)
149149
let terminalFrame = CGRect(
150150
x: activeInset,
151151
y: (height - terminalSize.height) / 2,
@@ -161,7 +161,7 @@ enum PresentationLayout {
161161
width: max(1, mikuMaxX - mikuMinX),
162162
height: mikuHeight
163163
)
164-
let idleSize = CGSize(width: 200, height: 340)
164+
let idleSize = CGSize(width: 220, height: 360)
165165
let idleEntryFrame = CGRect(
166166
x: width - MikuVisualTokens.space6 - idleSize.width,
167167
y: height - MikuVisualTokens.space6 - idleSize.height,
@@ -187,13 +187,18 @@ enum MikuVisualTokens {
187187
static let openingDuration = 0.24
188188
static let closingDuration = 0.18
189189
static let idleFloatDuration = 1.8
190+
static let idleWalkDuration = 4.6
191+
static let idleTurnDuration = 0.38
190192
static let pressDuration = 0.12
191193

192-
static let activeVeil = Color.black.opacity(0.18)
193-
static let bubbleTop = Color(red: 0.035, green: 0.070, blue: 0.105).opacity(0.94)
194-
static let bubbleBottom = Color(red: 0.010, green: 0.022, blue: 0.034).opacity(0.97)
195-
static let bubbleRim = Color.white.opacity(0.14)
196-
static let terminalText = Color.white.opacity(0.84)
194+
static let activeVeil = Color.black.opacity(0.22)
195+
static let bubbleTop = Color(red: 0.060, green: 0.067, blue: 0.075).opacity(0.98)
196+
static let bubbleBottom = Color(red: 0.025, green: 0.030, blue: 0.035).opacity(0.99)
197+
static let bubbleRim = Color.white.opacity(0.11)
198+
static let terminalText = Color.white.opacity(0.88)
199+
static let agentMeta = Color.white.opacity(0.46)
200+
static let agentHeader = Color.white.opacity(0.035)
201+
static let agentControl = Color.white.opacity(0.075)
197202
static let mikuTeal = Color(red: 0.224, green: 0.851, blue: 0.812)
198203
static let mikuTealDeep = Color(red: 0.067, green: 0.498, blue: 0.514)
199204
static let mikuNeutral = Color.white.opacity(0.82)

Sources/MikuCodeApp/MikuCodeApp.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ final class MikuApplicationDelegate: NSObject, NSApplicationDelegate {
4444
}
4545
}
4646

47+
@MainActor
4748
struct MikuRootView: View {
4849
@ObservedObject var presentation: AppPresentationStore
4950
@ObservedObject var terminalSession: TerminalSessionModel
@@ -100,6 +101,7 @@ struct MikuRootView: View {
100101
TerminalPanel(
101102
session: terminalSession,
102103
focusRequestID: presentation.focusRequestID,
104+
onClose: onClose,
103105
rendererPolicy: terminalRendererPolicy
104106
)
105107
.frame(
@@ -110,10 +112,14 @@ struct MikuRootView: View {
110112
x: metrics.terminalFrame.midX,
111113
y: metrics.terminalFrame.midY
112114
)
113-
MikuCompanion(scale: .active)
115+
Button(action: onClose) {
116+
MikuCompanion(scale: .active)
117+
}
118+
.buttonStyle(.plain)
114119
.frame(width: metrics.mikuFrame.width, height: metrics.mikuFrame.height)
115120
.position(x: metrics.mikuFrame.midX, y: metrics.mikuFrame.midY)
116-
.accessibilityHidden(true)
121+
.accessibilityLabel("Close terminal")
122+
.accessibilityHint("Return MikuCode to the desktop")
117123
}
118124
.accessibilityElement(children: .contain)
119125
.accessibilityLabel("MikuCode terminal")

Sources/MikuCodeApp/MikuCompanion.swift

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import AppKit
2+
import Combine
23
import SwiftUI
34

5+
@MainActor
46
struct MikuEntryButton: View {
57
@State private var isHovering = false
68
let action: () -> Void
@@ -51,6 +53,7 @@ struct MikuEntryButton: View {
5153
}
5254
}
5355

56+
@MainActor
5457
struct MikuCompanion: View {
5558
enum Scale {
5659
case idle
@@ -69,22 +72,45 @@ struct MikuCompanion: View {
6972
}
7073

7174
var body: some View {
72-
Image(nsImage: currentArtwork)
73-
.resizable()
74-
.interpolation(.high)
75-
.scaledToFit()
76-
.scaleEffect(looksAtUser ? 1.035 : 1)
77-
.saturation(looksAtUser ? 1.08 : 0.98)
78-
.brightness(looksAtUser ? 0.025 : 0)
79-
.offset(x: walkingOffset, y: floatingOffset)
80-
.shadow(
81-
color: MikuVisualTokens.mikuTeal.opacity(scale == .idle ? 0.14 : 0.10),
82-
radius: scale == .idle ? 12 : 18
83-
)
84-
.animation(.easeInOut(duration: 0.22), value: looksAtUser)
85-
.animation(.easeInOut(duration: 0.22), value: isWalkingRight)
86-
.onAppear { updateMotion() }
87-
.onChange(of: reduceMotion) { _, _ in updateMotion() }
75+
ZStack(alignment: .bottom) {
76+
if scale == .idle {
77+
Ellipse()
78+
.fill(MikuVisualTokens.mikuTeal.opacity(0.20))
79+
.frame(width: 108, height: 14)
80+
.blur(radius: 7)
81+
.offset(y: -14)
82+
.accessibilityHidden(true)
83+
}
84+
Image(nsImage: currentArtwork)
85+
.resizable()
86+
.interpolation(.high)
87+
.scaledToFit()
88+
.scaleEffect(scale == .idle ? 1.06 : 1)
89+
.scaleEffect(looksAtUser ? 1.035 : 1)
90+
.saturation(looksAtUser ? 1.08 : 0.98)
91+
.brightness(looksAtUser ? 0.025 : 0)
92+
.offset(x: walkingOffset, y: floatingOffset)
93+
.shadow(
94+
color: MikuVisualTokens.mikuTeal.opacity(scale == .idle ? 0.14 : 0.10),
95+
radius: scale == .idle ? 12 : 18
96+
)
97+
.animation(.easeInOut(duration: 0.22), value: looksAtUser)
98+
.animation(.easeInOut(duration: 0.22), value: isWalkingRight)
99+
.onAppear { updateMotion() }
100+
.onChange(of: reduceMotion) { _, _ in updateMotion() }
101+
.onReceive(
102+
Timer.publish(
103+
every: MikuVisualTokens.idleWalkDuration,
104+
on: .main,
105+
in: .common
106+
).autoconnect()
107+
) { _ in
108+
guard scale == .idle, !looksAtUser, !reduceMotion else { return }
109+
withAnimation(.easeInOut(duration: MikuVisualTokens.idleTurnDuration)) {
110+
isWalkingRight.toggle()
111+
}
112+
}
113+
}
88114
}
89115

90116
private var currentArtwork: NSImage {
@@ -96,7 +122,7 @@ struct MikuCompanion: View {
96122

97123
private var walkingOffset: CGFloat {
98124
guard scale == .idle, !looksAtUser, !reduceMotion else { return 0 }
99-
return isWalkingRight ? 16 : -16
125+
return isWalkingRight ? 30 : -30
100126
}
101127

102128
private var floatingOffset: CGFloat {
@@ -111,8 +137,7 @@ struct MikuCompanion: View {
111137
return
112138
}
113139
withAnimation(
114-
.easeInOut(duration: 4.8)
115-
.repeatForever(autoreverses: true)
140+
.easeInOut(duration: MikuVisualTokens.idleTurnDuration)
116141
) {
117142
isWalkingRight = true
118143
}
@@ -125,6 +150,7 @@ struct MikuCompanion: View {
125150
}
126151
}
127152

153+
@MainActor
128154
enum MikuArtwork {
129155
static let appIcon = image(named: "MikuCodeIcon")
130156
static let walkLeft = image(named: "MikuWalkLeft")

Sources/MikuCodeApp/MikuPanelCoordinator.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate {
5353
func completeOpening() {
5454
guard presentation.finishOpening() else { return }
5555
configurePanel(for: .terminal)
56+
guard ordersFront else { return }
57+
panel.makeKeyAndOrderFront(nil)
5658
}
5759

5860
func requestClose() {

Sources/MikuCodeApp/TerminalOutputTextView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ final class TerminalOutputTextView: NSTextView {
4848
setSelectedRange(NSRange(location: safeLocation, length: safeLength))
4949
}
5050

51+
override func mouseDown(with event: NSEvent) {
52+
window?.makeFirstResponder(self)
53+
super.mouseDown(with: event)
54+
}
55+
5156
override func keyDown(with event: NSEvent) {
5257
if event.keyCode == 116 {
5358
onPageUp?()

0 commit comments

Comments
 (0)