From b3b191c1783d7587557b5e1d86b95eb85aeefc15 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Fri, 24 Apr 2026 13:56:04 -0400 Subject: [PATCH 1/2] Add ability to rename terminals via UI and CLI (#203) Allow users to rename terminal tabs by double-clicking the tab name or via right-click context menu. Also adds `crow rename-terminal` CLI command and `rename-terminal` RPC handler for programmatic use. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Commands/TerminalCommands.swift | 24 ++++++++ .../Sources/CrowCLILib/CrowCommand.swift | 1 + .../CrowCore/Sources/CrowCore/AppState.swift | 3 + .../Sources/CrowUI/GlobalTerminalView.swift | 3 + .../Sources/CrowUI/SessionDetailView.swift | 55 ++++++++++++++++++- Sources/Crow/App/AppDelegate.swift | 27 +++++++++ Sources/Crow/App/SessionService.swift | 11 ++++ 7 files changed, 122 insertions(+), 2 deletions(-) diff --git a/Packages/CrowCLI/Sources/CrowCLILib/Commands/TerminalCommands.swift b/Packages/CrowCLI/Sources/CrowCLILib/Commands/TerminalCommands.swift index b473682..aeecd6c 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/Commands/TerminalCommands.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/Commands/TerminalCommands.swift @@ -71,6 +71,30 @@ public struct CloseTerminal: ParsableCommand { } } +/// Rename a terminal tab. +public struct RenameTerminal: ParsableCommand { + public static let configuration = CommandConfiguration(commandName: "rename-terminal", abstract: "Rename a terminal tab") + @Option(name: .long, help: "Session UUID") var session: String + @Option(name: .long, help: "Terminal UUID") var terminal: String + @Argument(help: "New name") var name: String + + public init() {} + + public func validate() throws { + try validateUUID(session, label: "session UUID") + try validateUUID(terminal, label: "terminal UUID") + } + + public func run() throws { + let result = try rpc("rename-terminal", params: [ + "session_id": .string(session), + "terminal_id": .string(terminal), + "name": .string(name), + ]) + printJSON(result) + } +} + /// Send text to a terminal tab. public struct Send: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "send", abstract: "Send text to a terminal") diff --git a/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift b/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift index dea4398..a2e3e44 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift @@ -24,6 +24,7 @@ public struct CrowCommand: ParsableCommand { NewTerminal.self, ListTerminals.self, CloseTerminal.self, + RenameTerminal.self, Send.self, AddLink.self, ListLinks.self, diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index eb513c5..9009caa 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -176,6 +176,9 @@ public final class AppState { /// Called to close a non-managed terminal tab. public var onCloseTerminal: ((UUID, UUID) -> Void)? // receives (sessionID, terminalID) + /// Called to rename a terminal tab. + public var onRenameTerminal: ((UUID, UUID, String) -> Void)? // receives (sessionID, terminalID, newName) + /// Called to add a new global terminal tab. public var onAddGlobalTerminal: (() -> Void)? diff --git a/Packages/CrowUI/Sources/CrowUI/GlobalTerminalView.swift b/Packages/CrowUI/Sources/CrowUI/GlobalTerminalView.swift index 7f20830..d54f496 100644 --- a/Packages/CrowUI/Sources/CrowUI/GlobalTerminalView.swift +++ b/Packages/CrowUI/Sources/CrowUI/GlobalTerminalView.swift @@ -58,6 +58,9 @@ public struct GlobalTerminalView: View { onClose: { id in appState.onCloseGlobalTerminal?(id) }, + onRename: { id, name in + appState.onRenameTerminal?(AppState.globalTerminalSessionID, id, name) + }, onAdd: { appState.onAddGlobalTerminal?() } diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index ae4ed4e..94d8d0c 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -245,6 +245,7 @@ public struct SessionDetailView: View { activeID: appState.activeTerminalID[session.id] ?? sessionTerminals[0].id, onSelect: { id in appState.activeTerminalID[session.id] = id }, onClose: { id in appState.onCloseTerminal?(session.id, id) }, + onRename: { id, name in appState.onRenameTerminal?(session.id, id, name) }, onAdd: { appState.onAddTerminal?(session.id) } ) Divider().overlay(CorveilTheme.borderSubtle) @@ -301,8 +302,13 @@ public struct TerminalTabBar: View { let activeID: UUID let onSelect: (UUID) -> Void let onClose: (UUID) -> Void + let onRename: (UUID, String) -> Void let onAdd: () -> Void + @State private var editingTerminalID: UUID? + @State private var editingName: String = "" + @FocusState private var isEditing: Bool + public var body: some View { HStack(spacing: 0) { ForEach(terminals) { terminal in @@ -310,8 +316,25 @@ public struct TerminalTabBar: View { HStack(spacing: 4) { Image(systemName: terminal.isManaged ? "sparkles" : "terminal") .font(.system(size: 9)) - Text(terminal.name) - .font(.caption) + if editingTerminalID == terminal.id { + TextField("Name", text: $editingName) + .textFieldStyle(.plain) + .font(.caption) + .frame(minWidth: 40, maxWidth: 120) + .focused($isEditing) + .onSubmit { + commitRename(terminal.id) + } + .onExitCommand { + editingTerminalID = nil + } + } else { + Text(terminal.name) + .font(.caption) + .onTapGesture(count: 2) { + beginEditing(terminal) + } + } if !terminal.isManaged { Button { onClose(terminal.id) @@ -331,6 +354,20 @@ public struct TerminalTabBar: View { .background(terminal.id == activeID ? CorveilTheme.gold.opacity(0.12) : Color.clear) } .buttonStyle(.plain) + .contextMenu { + Button { + beginEditing(terminal) + } label: { + Label("Rename", systemImage: "pencil") + } + if !terminal.isManaged { + Button(role: .destructive) { + onClose(terminal.id) + } label: { + Label("Close", systemImage: "xmark") + } + } + } } Button { onAdd() } label: { @@ -347,6 +384,20 @@ public struct TerminalTabBar: View { } .background(CorveilTheme.bgSurface) } + + private func beginEditing(_ terminal: SessionTerminal) { + editingTerminalID = terminal.id + editingName = terminal.name + isEditing = true + } + + private func commitRename(_ terminalID: UUID) { + let trimmed = editingName.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { + onRename(terminalID, trimmed) + } + editingTerminalID = nil + } } /// Badge displaying a session's current status with appropriate color. diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 60b52fd..702e264 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -170,6 +170,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState.onCloseTerminal = { [weak service] sessionID, terminalID in service?.closeTerminal(sessionID: sessionID, terminalID: terminalID) } + appState.onRenameTerminal = { [weak service] sessionID, terminalID, name in + service?.renameTerminal(sessionID: sessionID, terminalID: terminalID, name: name) + } appState.onAddGlobalTerminal = { [weak service] in service?.addGlobalTerminal() } @@ -714,6 +717,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return ["deleted": .bool(true)] } }, + "rename-terminal": { @Sendable params in + guard let sessionIDStr = params["session_id"]?.stringValue, + let sessionID = UUID(uuidString: sessionIDStr), + let terminalIDStr = params["terminal_id"]?.stringValue, + let terminalID = UUID(uuidString: terminalIDStr), + let name = params["name"]?.stringValue else { + throw RPCError.invalidParams("session_id, terminal_id, and name required") + } + guard AppDelegate.isValidSessionName(name) else { + throw RPCError.invalidParams("Invalid terminal name (max \(AppDelegate.maxSessionNameLength) chars, no control characters)") + } + return try await MainActor.run { + guard let idx = capturedAppState.terminals[sessionID]?.firstIndex(where: { $0.id == terminalID }) else { + throw RPCError.applicationError("Terminal not found") + } + capturedAppState.terminals[sessionID]![idx].name = name + capturedStore.mutate { data in + if let i = data.terminals.firstIndex(where: { $0.id == terminalID }) { + data.terminals[i].name = name + } + } + return ["terminal_id": .string(terminalIDStr), "name": .string(name)] + } + }, "send": { @Sendable params in guard let sessionIDStr = params["session_id"]?.stringValue, let sessionID = UUID(uuidString: sessionIDStr), diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 52869d6..4bfc89b 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -717,6 +717,17 @@ final class SessionService { store.mutate { data in data.terminals.removeAll { $0.id == terminalID } } } + /// Rename a terminal tab. + func renameTerminal(sessionID: UUID, terminalID: UUID, name: String) { + guard let idx = appState.terminals[sessionID]?.firstIndex(where: { $0.id == terminalID }) else { return } + appState.terminals[sessionID]![idx].name = name + store.mutate { data in + if let i = data.terminals.firstIndex(where: { $0.id == terminalID }) { + data.terminals[i].name = name + } + } + } + // MARK: - Global Terminal Management /// Add a new global terminal tab (not tied to any session). From f9d8895f361bb7726717fe3353561f9ad5926cc1 Mon Sep 17 00:00:00 2001 From: Danny Gershman Date: Fri, 24 Apr 2026 14:39:04 -0400 Subject: [PATCH 2/2] Address PR review: unify validation and fix focus-loss edge case - Move validation into SessionService.renameTerminal so both UI and CLI paths enforce the same rules - RPC handler now delegates to SessionService instead of inlining find/update/persist logic - Commit rename on focus loss so edit mode doesn't get stuck when the user clicks away from the TextField Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowUI/Sources/CrowUI/SessionDetailView.swift | 5 +++++ Sources/Crow/App/AppDelegate.swift | 13 ++----------- Sources/Crow/App/SessionService.swift | 9 ++++++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index 94d8d0c..06b5d05 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -328,6 +328,11 @@ public struct TerminalTabBar: View { .onExitCommand { editingTerminalID = nil } + .onChange(of: isEditing) { _, nowEditing in + if !nowEditing, editingTerminalID == terminal.id { + commitRename(terminal.id) + } + } } else { Text(terminal.name) .font(.caption) diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 702e264..bfb17c8 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -725,18 +725,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let name = params["name"]?.stringValue else { throw RPCError.invalidParams("session_id, terminal_id, and name required") } - guard AppDelegate.isValidSessionName(name) else { - throw RPCError.invalidParams("Invalid terminal name (max \(AppDelegate.maxSessionNameLength) chars, no control characters)") - } return try await MainActor.run { - guard let idx = capturedAppState.terminals[sessionID]?.firstIndex(where: { $0.id == terminalID }) else { - throw RPCError.applicationError("Terminal not found") - } - capturedAppState.terminals[sessionID]![idx].name = name - capturedStore.mutate { data in - if let i = data.terminals.firstIndex(where: { $0.id == terminalID }) { - data.terminals[i].name = name - } + guard capturedService.renameTerminal(sessionID: sessionID, terminalID: terminalID, name: name) else { + throw RPCError.applicationError("Terminal not found or invalid name") } return ["terminal_id": .string(terminalIDStr), "name": .string(name)] } diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 4bfc89b..afbbe93 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -717,15 +717,18 @@ final class SessionService { store.mutate { data in data.terminals.removeAll { $0.id == terminalID } } } - /// Rename a terminal tab. - func renameTerminal(sessionID: UUID, terminalID: UUID, name: String) { - guard let idx = appState.terminals[sessionID]?.firstIndex(where: { $0.id == terminalID }) else { return } + /// Rename a terminal tab. Returns `false` if the terminal was not found or the name is invalid. + @discardableResult + func renameTerminal(sessionID: UUID, terminalID: UUID, name: String) -> Bool { + guard Validation.isValidSessionName(name), + let idx = appState.terminals[sessionID]?.firstIndex(where: { $0.id == terminalID }) else { return false } appState.terminals[sessionID]![idx].name = name store.mutate { data in if let i = data.terminals.firstIndex(where: { $0.id == terminalID }) { data.terminals[i].name = name } } + return true } // MARK: - Global Terminal Management