Skip to content

Commit 6a18aa8

Browse files
authored
perf: cut tab/workspace switch latency and background churn (#108)
* app: cut tab and workspace switch latency Three sources of switching lag, measured against the live app (raw tmux select-window is ~18ms; Mori took 110-160ms end to end before the tmux window actually flipped): - selectWindow/selectPane issued the tmux command in a main-actor Task, which queued behind the selection-triggered UI re-render (~60-100ms). The command is now Task.detached and fired before any state mutation. - selectWorktree serialized terminal attach behind a git status, a list-sessions round trip, and a full tmux rescan. When the last poll (<=5s) saw the session alive, attach now happens immediately and the reconcile chain runs behind it. - The terminal surface cache held only 3 surfaces, so cycling through more than three workspaces destroyed and respawned a login shell + tmux attach on every switch. Raised to 10. * tmux: collapse the runtime scan into a single list-panes -a scanAll ran 1 + sessions + windows subprocess invocations every 5s poll (dozens of SSH round trips for remote endpoints). A single list-panes -a row per pane carries session and window context; parseScan groups rows back into the session tree. tmux guarantees every session has >=1 window and every window >=1 pane, so grouping loses no nodes. Window currentPath keeps list-windows semantics by resolving from the active pane. * ui: stop per-frame agent-pulse renders and redundant tab rebuilds The sidebar's agent working icon used a SwiftUI repeatForever opacity animation, which re-rendered the hosting view's display list every frame for as long as any agent was working (~12% of the main thread at idle, sampled). It now pulses via a repeating CABasicAnimation on the layer, which runs on the render server. TerminalTabsBarView's observation fires for any runtimeWindows change (every poll); it tore down and recreated every tab NSView each time. It now snapshots what it last rendered and skips rebuilds when the strip's windows and selection are unchanged. * docs: changelog entries for switching performance work
1 parent 10131f5 commit 6a18aa8

9 files changed

Lines changed: 296 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- **iOS (MoriRemote)**: The workspace sidebar gains a bottom quick-filter field (cmd+p style) — type to narrow projects, sessions, branches, and windows by name, title, or path. Matching a session keeps its whole group; otherwise only matching windows are shown. Thumb-reachable at the bottom edge, and it rides above the keyboard.
1717
- **iOS (MoriRemote)**: The keyboard-dismiss button is now pinned at the far left of the accessory key bar, outside the scrollable key row — it used to sit at the row's right end, forcing a swipe to the very end just to put the keyboard away.
1818

19+
### ⚡ Performance
20+
21+
- **macOS**: Tab and workspace switching is much snappier. The `tmux select-window` call no longer queues behind the selection-triggered UI re-render (~60–100ms saved per tab switch), switching to a workspace whose session was seen alive by the last poll attaches the terminal immediately instead of waiting for a git branch check plus a full tmux rescan, and the terminal surface cache grew from 3 to 10 so cycling through more than three workspaces stops destroying and respawning shells.
22+
- **macOS**: Less background churn. The 5-second runtime scan is now a single `tmux list-panes -a` invocation instead of one subprocess per session and window (one SSH round trip instead of dozens for remote workspaces), the terminal tab strip only rebuilds when the windows it shows actually change, and the sidebar's agent "breathing" icon pulses via Core Animation instead of re-rendering the SwiftUI tree every frame while an agent is working.
23+
1924
### 🐛 Bug Fixes
2025

2126
- **iOS (MoriRemote)**: Switching to a tmux window no longer lands you in a stuck pane showing `(repeat) N` at the bottom. Panes that had been scrolled stay in tmux copy-mode across window switches, where digit keys hit tmux's default `(repeat)` command-prompt binding and swallow input; sidebar switches now cancel copy-mode on the target pane so it's immediately typeable.

CHANGELOG.zh-Hans.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
- **iOS (MoriRemote)**:工作区侧栏底部新增快速筛选框(类似 cmd+p)——输入即可按名称、标题或路径过滤项目、会话、分支和窗口。匹配到会话时保留整组;否则只显示匹配的窗口。位于底部方便拇指操作,弹出键盘时会自动上浮。
1717
- **iOS (MoriRemote)**:收起键盘按钮固定在快捷键条最左侧、可滚动按键区之外——此前它在滚动条最右端,每次收键盘都要滑到最末尾。
1818

19+
### ⚡ 性能
20+
21+
- **macOS**:标签页与工作区切换明显更跟手。`tmux select-window` 不再排在选中态触发的 UI 重渲染之后(每次切换省约 60–100ms);切换到上次轮询确认存活的工作区会立即挂载终端,不再等待 git 分支检查和全量 tmux 扫描;终端 surface 缓存从 3 个提升到 10 个,超过三个工作区来回切换时不再反复销毁重建 shell。
22+
- **macOS**:后台开销更低。每 5 秒的运行时扫描合并为单条 `tmux list-panes -a`(原来每个 session、每个 window 各起一个子进程,远程工作区从几十次 SSH 往返降为一次);终端标签条只在其显示内容真正变化时才重建;侧边栏 agent"呼吸"图标改用 Core Animation 脉动,agent 工作期间不再逐帧重渲染 SwiftUI 树。
23+
1924
### 🐛 问题修复
2025

2126
- **iOS (MoriRemote)**:切换 tmux 窗口后不再出现底部显示 `(repeat) N`、无法输入的卡死状态。滚动过的 pane 会一直停留在 tmux copy-mode,切换窗口也不退出,此时数字键会命中 tmux 默认的 `(repeat)` 命令提示绑定并吞掉输入;现在从侧栏切换时会自动退出目标 pane 的 copy-mode,切过去即可直接输入。

Packages/MoriTmux/Sources/MoriTmux/TmuxBackend.swift

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -82,32 +82,13 @@ public actor TmuxBackend: TmuxControlling {
8282
}
8383

8484
public func scanAll() async throws -> [TmuxSession] {
85-
// 1. List all sessions
86-
let sessionsOutput = try await runner.run(
87-
"list-sessions", "-F", TmuxParser.sessionFormat
85+
// A single list-panes -a returns every session/window/pane in one tmux
86+
// invocation (one subprocess locally, one round trip over SSH) instead
87+
// of 1 + sessions + windows separate ones.
88+
let output = try await runner.run(
89+
"list-panes", "-a", "-F", TmuxParser.scanFormat
8890
)
89-
var sessions = TmuxParser.parseSessions(sessionsOutput)
90-
91-
// 2. For each session, list windows
92-
for i in sessions.indices {
93-
let windowsOutput = try await runner.run(
94-
"list-windows", "-t", sessions[i].sessionId,
95-
"-F", TmuxParser.windowFormat
96-
)
97-
sessions[i].windows = TmuxParser.parseWindows(windowsOutput)
98-
99-
// 3. For each window, list panes
100-
for j in sessions[i].windows.indices {
101-
let target = "\(sessions[i].sessionId):\(sessions[i].windows[j].windowId)"
102-
let panesOutput = try await runner.run(
103-
"list-panes", "-t", target,
104-
"-F", TmuxParser.paneFormat
105-
)
106-
sessions[i].windows[j].panes = TmuxParser.parsePanes(panesOutput)
107-
}
108-
}
109-
110-
return sessions
91+
return TmuxParser.parseScan(output)
11192
}
11293

11394
/// List tmux session names without deep window/pane scans.

Packages/MoriTmux/Sources/MoriTmux/TmuxParser.swift

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,90 @@ public enum TmuxParser {
4343
"#{@mori-agent-name}",
4444
].joined(separator: delimiter)
4545

46+
/// Format string for the single-command full scan (`tmux list-panes -a -F`).
47+
/// One row per pane, prefixed with its session and window context so the whole
48+
/// session → window → pane tree comes back in a single tmux invocation instead
49+
/// of 1 + sessions + windows separate ones. tmux guarantees every session has
50+
/// at least one window and every window at least one pane, so grouping rows
51+
/// loses no nodes.
52+
public static let scanFormat: String = [
53+
"#{session_id}",
54+
"#{session_name}",
55+
"#{session_windows}",
56+
"#{session_attached}",
57+
"#{window_id}",
58+
"#{window_index}",
59+
"#{window_name}",
60+
"#{window_active}",
61+
].joined(separator: delimiter) + delimiter + paneFormat
62+
63+
/// Number of session/window context fields prefixed to each `scanFormat` row.
64+
private static let scanContextFieldCount = 8
65+
4666
// MARK: - Parsing
4767

68+
/// Parse the output of `tmux list-panes -a -F scanFormat` into the full
69+
/// session tree. Rows are grouped by session and window in output order.
70+
/// A window's `currentPath` mirrors `list-windows` semantics (which resolves
71+
/// pane variables against the active pane): it is taken from the window's
72+
/// active pane, falling back to the first pane.
73+
public static func parseScan(_ output: String) -> [TmuxSession] {
74+
var sessions: [TmuxSession] = []
75+
var sessionIndexById: [String: Int] = [:]
76+
var windowIndexById: [String: [String: Int]] = [:]
77+
78+
for fields in parseLines(output) {
79+
guard fields.count >= scanContextFieldCount + 5,
80+
let pane = parsePane(Array(fields[scanContextFieldCount...])) else { continue }
81+
let sessionId = fields[0]
82+
83+
let sessionIndex: Int
84+
if let existing = sessionIndexById[sessionId] {
85+
sessionIndex = existing
86+
} else {
87+
sessionIndex = sessions.count
88+
sessionIndexById[sessionId] = sessionIndex
89+
sessions.append(TmuxSession(
90+
sessionId: sessionId,
91+
name: fields[1],
92+
windowCount: Int(fields[2]) ?? 0,
93+
isAttached: fields[3] == "1"
94+
))
95+
}
96+
97+
let windowId = fields[4]
98+
let windowIndex: Int
99+
if let existing = windowIndexById[sessionId]?[windowId] {
100+
windowIndex = existing
101+
} else {
102+
windowIndex = sessions[sessionIndex].windows.count
103+
windowIndexById[sessionId, default: [:]][windowId] = windowIndex
104+
sessions[sessionIndex].windows.append(TmuxWindow(
105+
windowId: windowId,
106+
windowIndex: Int(fields[5]) ?? 0,
107+
name: fields[6],
108+
isActive: fields[7] == "1"
109+
))
110+
}
111+
112+
let window = sessions[sessionIndex].windows[windowIndex]
113+
var panes = window.panes
114+
panes.append(pane)
115+
let currentPath = panes.first(where: { $0.isActive })?.currentPath
116+
?? panes.first?.currentPath
117+
sessions[sessionIndex].windows[windowIndex] = TmuxWindow(
118+
windowId: window.windowId,
119+
windowIndex: window.windowIndex,
120+
name: window.name,
121+
isActive: window.isActive,
122+
currentPath: currentPath,
123+
panes: panes
124+
)
125+
}
126+
127+
return sessions
128+
}
129+
48130
/// Parse the output of `tmux list-sessions -F` into `TmuxSession` models.
49131
public static func parseSessions(_ output: String) -> [TmuxSession] {
50132
parseLines(output).compactMap { fields in
@@ -83,7 +165,11 @@ public enum TmuxParser {
83165

84166
/// Parse the output of `tmux list-panes -F` into `TmuxPane` models.
85167
public static func parsePanes(_ output: String) -> [TmuxPane] {
86-
parseLines(output).compactMap { fields in
168+
parseLines(output).compactMap(parsePane)
169+
}
170+
171+
/// Parse a single `paneFormat` field row into a `TmuxPane`.
172+
private static func parsePane(_ fields: [String]) -> TmuxPane? {
87173
guard fields.count >= 5 else { return nil }
88174
let paneId = fields[0]
89175
let tty = fields[1].isEmpty ? nil : fields[1]
@@ -109,7 +195,6 @@ public enum TmuxParser {
109195
agentState: agentState,
110196
agentName: agentName
111197
)
112-
}
113198
}
114199

115200
// MARK: - Private

Packages/MoriTmux/Tests/MoriTmuxTests/main.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,70 @@ func testParsePanesEmpty() {
138138
assertEqual(panes.count, 0)
139139
}
140140

141+
// MARK: - TmuxParser: Full Scan Parsing Tests
142+
143+
func testParseScanGroupsSessionsWindowsPanes() {
144+
// Two sessions; first session has two windows, second window has two panes.
145+
let output = """
146+
$0\tdev\t2\t1\t@0\t1\tzsh\t1\t%0\t/dev/ttys001\t1\t/Users/test\tzsh\t100\tzsh\t90\t111\t\t
147+
$0\tdev\t2\t1\t@1\t2\tvim\t0\t%1\t/dev/ttys002\t0\t/Users/test/src\tvim\t200\tvim\t190\t222\t\t
148+
$0\tdev\t2\t1\t@1\t2\tvim\t0\t%2\t/dev/ttys003\t1\t/Users/test/docs\tless\t300\tless\t290\t333\tworking\tclaude
149+
$1\tmori/main\t1\t0\t@2\t1\tnode\t1\t%3\t/dev/ttys004\t1\t/Users/test/mori\tnode\t400\tnode\t390\t444\t\t
150+
"""
151+
let sessions = TmuxParser.parseScan(output)
152+
assertEqual(sessions.count, 2)
153+
154+
assertEqual(sessions[0].sessionId, "$0")
155+
assertEqual(sessions[0].name, "dev")
156+
assertEqual(sessions[0].windowCount, 2)
157+
assertTrue(sessions[0].isAttached)
158+
assertEqual(sessions[0].windows.count, 2)
159+
160+
assertEqual(sessions[0].windows[0].windowId, "@0")
161+
assertEqual(sessions[0].windows[0].windowIndex, 1)
162+
assertEqual(sessions[0].windows[0].name, "zsh")
163+
assertTrue(sessions[0].windows[0].isActive)
164+
assertEqual(sessions[0].windows[0].panes.count, 1)
165+
assertEqual(sessions[0].windows[0].panes[0].paneId, "%0")
166+
167+
assertEqual(sessions[0].windows[1].windowId, "@1")
168+
assertFalse(sessions[0].windows[1].isActive)
169+
assertEqual(sessions[0].windows[1].panes.count, 2)
170+
assertEqual(sessions[0].windows[1].panes[0].paneId, "%1")
171+
assertEqual(sessions[0].windows[1].panes[1].paneId, "%2")
172+
assertEqual(sessions[0].windows[1].panes[1].agentState, "working")
173+
assertEqual(sessions[0].windows[1].panes[1].agentName, "claude")
174+
175+
assertEqual(sessions[1].sessionId, "$1")
176+
assertEqual(sessions[1].name, "mori/main")
177+
assertFalse(sessions[1].isAttached)
178+
assertEqual(sessions[1].windows.count, 1)
179+
assertEqual(sessions[1].windows[0].panes[0].paneId, "%3")
180+
}
181+
182+
func testParseScanWindowPathFromActivePane() {
183+
// list-windows resolves pane variables against the active pane; the scan
184+
// groups panes itself, so the window path must come from the active pane
185+
// (%2 here), not the first one.
186+
let output = """
187+
$0\tdev\t1\t1\t@0\t1\tzsh\t1\t%1\t/dev/ttys002\t0\t/Users/test/src\tvim\t200\tvim\t190\t222\t\t
188+
$0\tdev\t1\t1\t@0\t1\tzsh\t1\t%2\t/dev/ttys003\t1\t/Users/test/docs\tless\t300\tless\t290\t333\t\t
189+
"""
190+
let sessions = TmuxParser.parseScan(output)
191+
assertEqual(sessions.count, 1)
192+
assertEqual(sessions[0].windows.count, 1)
193+
assertEqual(sessions[0].windows[0].currentPath, "/Users/test/docs")
194+
}
195+
196+
func testParseScanEmpty() {
197+
assertEqual(TmuxParser.parseScan("").count, 0)
198+
}
199+
200+
func testParseScanMalformed() {
201+
let sessions = TmuxParser.parseScan("$0\tdev\t1\t1\t@0\n")
202+
assertEqual(sessions.count, 0, "Should skip rows missing pane fields")
203+
}
204+
141205
func testParsePanesWithActivity() {
142206
let output = "%0\t/dev/ttys001\t1\t/Users/test\tzsh\t1710784200\n"
143207
let panes = TmuxParser.parsePanes(output)
@@ -653,6 +717,10 @@ testParsePanesSingle()
653717
testParsePanesMultiple()
654718
testParsePanesEmptyOptionals()
655719
testParsePanesEmpty()
720+
testParseScanGroupsSessionsWindowsPanes()
721+
testParseScanWindowPathFromActivePane()
722+
testParseScanEmpty()
723+
testParseScanMalformed()
656724
testParsePanesWithActivity()
657725
testParsePanesMultipleWithActivity()
658726
testParsePanesActivityEmptyField()

Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -592,28 +592,83 @@ private enum SidebarStatus { case waiting, running, idle, error }
592592
/// (template-tinted to the running colour), or a generic AI glyph when the agent
593593
/// is unknown. Breathes to signal live activity — asset images can't carry an SF
594594
/// `symbolEffect`, so the pulse is a plain opacity animation.
595-
private struct AgentWorkingIcon: View {
595+
private struct AgentWorkingIcon: NSViewRepresentable {
596596
let asset: String?
597597
let color: Color
598-
@State private var breathing = false
599-
var body: some View {
600-
Group {
601-
if let asset, let image = AgentIconLoader.image(named: asset) {
602-
Image(nsImage: image)
603-
.renderingMode(.template)
604-
.resizable()
605-
.scaledToFit()
606-
.frame(width: 12, height: 12)
607-
} else {
608-
Image(systemName: "sparkles")
609-
.font(.system(size: 11, weight: .medium))
610-
}
598+
599+
func makeNSView(context: Context) -> PulsingIconView {
600+
PulsingIconView()
601+
}
602+
603+
func updateNSView(_ view: PulsingIconView, context: Context) {
604+
let image: NSImage?
605+
if let asset, let assetImage = AgentIconLoader.image(named: asset) {
606+
image = assetImage
607+
} else {
608+
image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)?
609+
.withSymbolConfiguration(.init(pointSize: 11, weight: .medium))
611610
}
612-
.foregroundStyle(color)
613-
.opacity(breathing ? 0.45 : 1)
614-
.animation(.easeInOut(duration: 0.85).repeatForever(autoreverses: true), value: breathing)
615-
.onAppear { breathing = true }
611+
view.configure(image: image, tint: NSColor(color))
612+
}
613+
614+
func sizeThatFits(_ proposal: ProposedViewSize, nsView: PulsingIconView, context: Context) -> NSSize? {
615+
NSSize(width: 12, height: 12)
616+
}
617+
}
618+
619+
/// Pulses via a repeating Core Animation opacity animation on the layer instead
620+
/// of a SwiftUI `repeatForever` animation: CA runs on the render server, while
621+
/// the SwiftUI animation forced the hosting view to re-render its display list
622+
/// every frame for as long as any agent was working.
623+
private final class PulsingIconView: NSView {
624+
private let imageView = NSImageView()
625+
626+
override init(frame: NSRect) {
627+
super.init(frame: frame)
628+
wantsLayer = true
629+
imageView.imageScaling = .scaleProportionallyUpOrDown
630+
imageView.translatesAutoresizingMaskIntoConstraints = false
631+
addSubview(imageView)
632+
NSLayoutConstraint.activate([
633+
imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
634+
imageView.centerYAnchor.constraint(equalTo: centerYAnchor),
635+
imageView.widthAnchor.constraint(lessThanOrEqualToConstant: 12),
636+
imageView.heightAnchor.constraint(lessThanOrEqualToConstant: 12),
637+
])
616638
}
639+
640+
@available(*, unavailable)
641+
required init?(coder: NSCoder) {
642+
fatalError("init(coder:) has not been implemented")
643+
}
644+
645+
override var intrinsicContentSize: NSSize {
646+
NSSize(width: 12, height: 12)
647+
}
648+
649+
func configure(image: NSImage?, tint: NSColor) {
650+
image?.isTemplate = true
651+
imageView.image = image
652+
imageView.contentTintColor = tint
653+
}
654+
655+
override func viewDidMoveToWindow() {
656+
super.viewDidMoveToWindow()
657+
// CA strips animations whenever the layer leaves the tree (row scrolled
658+
// out, window changes), so re-add on every attach.
659+
guard window != nil, let layer else { return }
660+
guard layer.animation(forKey: Self.pulseKey) == nil else { return }
661+
let pulse = CABasicAnimation(keyPath: "opacity")
662+
pulse.fromValue = 1.0
663+
pulse.toValue = 0.45
664+
pulse.duration = 0.85
665+
pulse.autoreverses = true
666+
pulse.repeatCount = .infinity
667+
pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
668+
layer.add(pulse, forKey: Self.pulseKey)
669+
}
670+
671+
private static let pulseKey = "mori.agent.pulse"
617672
}
618673

619674
/// Loads bundled agent SVGs as tintable template images. SwiftPM doesn't compile

Sources/Mori/App/TerminalAreaViewController.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ final class TerminalAreaViewController: NSViewController, ThemedSurface {
112112
init(terminalHost: TerminalHost? = nil) {
113113
let host = terminalHost ?? GhosttyAdapter()
114114
self.terminalHost = host
115-
self.surfaceCache = TerminalSurfaceCache(maxSize: 3, terminalHost: host)
115+
// Each cached surface keeps a live tmux client + shell attached; evicting
116+
// one means a full shell restart + reattach on the next switch, which is
117+
// the dominant cost of workspace switching. 10 covers typical workspace
118+
// counts; raise if users report thrash with more.
119+
self.surfaceCache = TerminalSurfaceCache(maxSize: 10, terminalHost: host)
116120
super.init(nibName: nil, bundle: nil)
117121
installSurfaceCloseObserver()
118122
}

Sources/Mori/App/TerminalTabsBarView.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,22 @@ final class TerminalTabsBarView: NSView {
9191
])
9292
}
9393

94+
/// Snapshot of the last rendered state. Observation fires for any change to
95+
/// the tracked state (every poll touches `runtimeWindows`); comparing against
96+
/// the snapshot skips tearing down and recreating every tab view when nothing
97+
/// this strip displays actually changed.
98+
private var renderedWindows: [RuntimeWindow]?
99+
private var renderedSelectedWindowId: String?
100+
94101
private func updateAndObserve() {
95102
withObservationTracking {
96-
rebuildTabs(
97-
windows: appState.windowsForSelectedWorktree,
98-
selectedWindowId: appState.uiState.selectedWindowId
99-
)
103+
let windows = appState.windowsForSelectedWorktree
104+
let selectedWindowId = appState.uiState.selectedWindowId
105+
if windows != renderedWindows || selectedWindowId != renderedSelectedWindowId {
106+
renderedWindows = windows
107+
renderedSelectedWindowId = selectedWindowId
108+
rebuildTabs(windows: windows, selectedWindowId: selectedWindowId)
109+
}
100110
} onChange: { [weak self] in
101111
Task { @MainActor [weak self] in
102112
self?.updateAndObserve()

0 commit comments

Comments
 (0)