Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 16 additions & 50 deletions RxCode/Views/Inspector/InspectorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import RxCodeCore
struct InspectorContentView: View {
@Environment(WindowState.self) private var windowState

let terminalsBySession: [String: [InspectorTerminal]]
let currentSessionKey: String
let terminals: [InspectorTerminal]
let activeTerminalId: UUID?
let memoClearID: UUID?
let terminalFocusID: UUID?
Expand All @@ -21,84 +20,51 @@ struct InspectorContentView: View {
let onAddTerminal: () -> Void
let onRenameTerminal: (UUID, String) -> Void

private struct FlatEntry: Identifiable {
let sessionKey: String
let terminalId: UUID
let process: TerminalProcess
let resetID: UUID
var id: String { "\(sessionKey)|\(terminalId.uuidString)" }
}

/// Flattened list of every terminal across every session so they all stay
/// mounted (and their shells keep running) regardless of the active thread.
private var allEntries: [FlatEntry] {
terminalsBySession
.sorted { $0.key < $1.key }
.flatMap { sessionKey, terminals in
terminals.map { t in
FlatEntry(
sessionKey: sessionKey,
terminalId: t.id,
process: t.process,
resetID: t.resetID
)
}
}
}

private var currentTerminals: [InspectorTerminal] {
terminalsBySession[currentSessionKey] ?? []
private var activeTerminal: InspectorTerminal? {
guard let activeTerminalId else { return nil }
return terminals.first { $0.id == activeTerminalId }
}

var body: some View {
VStack(spacing: 0) {
switch windowState.inspectorTab {
case .terminal:
VStack(spacing: 0) {
if currentTerminals.count > 1 || !currentTerminals.isEmpty {
if !terminals.isEmpty {
terminalTabBar
}
ZStack {
ForEach(allEntries) { entry in
let isActive = entry.sessionKey == currentSessionKey
&& entry.terminalId == activeTerminalId
Group {
if let activeTerminal {
EmbeddedTerminalView(
executable: "/bin/zsh",
arguments: ["-il"],
currentDirectory: windowState.selectedProject?.path,
process: entry.process,
focusTrigger: isActive ? terminalFocusID : nil
process: activeTerminal.process,
focusTrigger: terminalFocusID
)
.id(entry.resetID)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
.id(activeTerminal.resetID)
}
}
.padding(8)
.background(ClaudeTheme.codeBackground)
}
.frame(maxHeight: windowState.inspectorTab == .terminal ? .infinity : 0)
.clipped()

case .memo:
InspectorMemoPanel(projectId: windowState.selectedProject?.id,
clearTrigger: memoClearID,
focusTrigger: memoFocusID)
.frame(maxHeight: windowState.inspectorTab == .memo ? .infinity : 0)
.clipped()

case .run:
RunOutputInspectorView()
.frame(maxHeight: windowState.inspectorTab == .run ? .infinity : 0)
.clipped()
}
}

@ViewBuilder
private var terminalTabBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(Array(currentTerminals.enumerated()), id: \.element.id) { index, terminal in
ForEach(Array(terminals.enumerated()), id: \.element.id) { index, terminal in
TerminalTabChip(
title: terminal.customTitle ?? "Terminal \(index + 1)",
isActive: terminal.id == activeTerminalId,
canClose: currentTerminals.count > 1,
canClose: terminals.count > 1,
onSelect: { onSelectTerminal(terminal.id) },
onClose: { onCloseTerminal(terminal.id) },
onRename: { newTitle in onRenameTerminal(terminal.id, newTitle) }
Expand Down
68 changes: 50 additions & 18 deletions RxCode/Views/Inspector/RightInspectorPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@ struct InspectorTerminal: Identifiable {
}

struct RightInspectorPanel: View {
private static let maximumRetainedTerminalSessions = 12

let maxAllowedWidth: CGFloat

@Environment(AppState.self) private var appState
@Environment(WindowState.self) private var windowState

// Per-thread terminal storage. Each session/thread can have multiple
// terminals; all stay alive across thread switches.
// terminals. Inactive sessions are retained up to a bounded LRU limit.
@State private var terminalsBySession: [String: [InspectorTerminal]] = [:]
@State private var activeTerminalIdBySession: [String: UUID] = [:]
@State private var terminalSessionAccessOrder: [String] = []
@State private var memoClearID: UUID? = nil
@State private var terminalFocusID: UUID? = nil
@State private var memoFocusID: UUID? = nil
Expand All @@ -46,6 +49,12 @@ struct RightInspectorPanel: View {
appState.showRightSidebar
}

private var terminalIsVisible: Bool {
showRightSidebar
&& windowState.inspectorMode == .inspector
&& windowState.inspectorTab == .terminal
}

private var currentTerminals: [InspectorTerminal] {
terminalsBySession[currentSessionKey] ?? []
}
Expand Down Expand Up @@ -76,6 +85,26 @@ struct RightInspectorPanel: View {
let first = terminalsBySession[key]?.first {
activeTerminalIdBySession[key] = first.id
}
retainTerminalSession(key)
}

private func ensureTerminalIfVisible() {
guard terminalIsVisible else { return }
ensureTerminal(for: currentSessionKey)
}

private func retainTerminalSession(_ key: String) {
terminalSessionAccessOrder.removeAll { $0 == key }
terminalSessionAccessOrder.append(key)

while terminalSessionAccessOrder.count > Self.maximumRetainedTerminalSessions {
let evictedKey = terminalSessionAccessOrder.removeFirst()
guard evictedKey != key else { continue }
terminalsBySession.removeValue(forKey: evictedKey)?.forEach { terminal in
terminal.process.terminate()
}
activeTerminalIdBySession.removeValue(forKey: evictedKey)
}
}

private func addTerminalToCurrent() {
Expand Down Expand Up @@ -145,18 +174,19 @@ struct RightInspectorPanel: View {
case .review:
reviewContent
case .inspector:
InspectorContentView(
terminalsBySession: terminalsBySession,
currentSessionKey: currentSessionKey,
activeTerminalId: activeTerminalId,
memoClearID: memoClearID,
terminalFocusID: terminalFocusID,
memoFocusID: memoFocusID,
onSelectTerminal: selectTerminal,
onCloseTerminal: closeTerminal,
onAddTerminal: addTerminalToCurrent,
onRenameTerminal: renameTerminal
)
if showRightSidebar {
InspectorContentView(
terminals: currentTerminals,
activeTerminalId: activeTerminalId,
memoClearID: memoClearID,
terminalFocusID: terminalFocusID,
memoFocusID: memoFocusID,
onSelectTerminal: selectTerminal,
onCloseTerminal: closeTerminal,
onAddTerminal: addTerminalToCurrent,
onRenameTerminal: renameTerminal
)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Expand All @@ -179,21 +209,23 @@ struct RightInspectorPanel: View {
.clipped()
.background(terminalShortcuts)
.task(id: currentSessionKey) {
// Ensure the terminal process exists for this session. The panel's
// visibility is owned by the workspace-level AppState — do not
// force it open here, or the user could never close it.
ensureTerminal(for: currentSessionKey)
ensureTerminalIfVisible()
}
.onChange(of: windowState.inspectorTab) { _, newTab in
if windowState.inspectorMode == .inspector { bumpFocus(for: newTab) }
if windowState.inspectorMode == .inspector {
ensureTerminalIfVisible()
bumpFocus(for: newTab)
}
}
.onChange(of: windowState.inspectorMode) { _, newMode in
if newMode == .inspector, showRightSidebar {
ensureTerminalIfVisible()
bumpFocus(for: windowState.inspectorTab)
}
}
.onChange(of: showRightSidebar) { _, isShowing in
if isShowing, windowState.inspectorMode == .inspector {
ensureTerminalIfVisible()
bumpFocus(for: windowState.inspectorTab)
}
}
Expand Down
5 changes: 5 additions & 0 deletions RxCode/Views/Terminal/TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ struct EmbeddedTerminalView: NSViewRepresentable {
var focusTrigger: UUID? = nil

func makeNSView(context: Context) -> LocalProcessTerminalView {
if let tv = process?.terminalView, process?.terminated == false {
tv.processDelegate = context.coordinator
return tv
}

let tv = LocalProcessTerminalView(frame: .zero)

// Set terminal background/foreground colors to match the theme
Expand Down
10 changes: 8 additions & 2 deletions RxCodeTests/CrossProjectSendConcurrencyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,12 @@ final class CrossProjectSendConcurrencyTests: XCTestCase {
XCTAssertEqual(completion.sessionId, sessionId)
XCTAssertEqual(completion.assistantText, expectedText)
XCTAssertNil(completion.error)
// Budget is generous because the measured time is dominated by MainActor
// scheduling latency, which spikes well past 50 ms on loaded CI runners.
// 0.5 s still catches the original regression class (poll-based waits
// and multi-second stream starvation) without flaking.
XCTAssertLessThan(
elapsed, 0.05,
elapsed, 0.5,
"awaitStreamCompletion took \(elapsed)s after record() — should be near-instant via continuation handoff"
)
}
Expand All @@ -240,7 +244,9 @@ final class CrossProjectSendConcurrencyTests: XCTestCase {
let elapsed = Date().timeIntervalSince(startedAt)

XCTAssertEqual(resolved, realSid)
XCTAssertLessThan(elapsed, 0.05, "session-rename waiter took \(elapsed)s — should be near-instant")
// See testRecordStreamCompletionResumesAwaiterImmediately for why the
// budget is 0.5 s: MainActor scheduling latency dominates on busy CI.
XCTAssertLessThan(elapsed, 0.5, "session-rename waiter took \(elapsed)s — should be near-instant")
}

// MARK: - Provider-native session ids
Expand Down
Loading