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..06b5d05 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,30 @@ 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 + } + .onChange(of: isEditing) { _, nowEditing in + if !nowEditing, editingTerminalID == terminal.id { + commitRename(terminal.id) + } + } + } else { + Text(terminal.name) + .font(.caption) + .onTapGesture(count: 2) { + beginEditing(terminal) + } + } if !terminal.isManaged { Button { onClose(terminal.id) @@ -331,6 +359,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 +389,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..bfb17c8 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,21 @@ 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") + } + return try await MainActor.run { + 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)] + } + }, "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..afbbe93 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -717,6 +717,20 @@ final class SessionService { store.mutate { data in data.terminals.removeAll { $0.id == terminalID } } } + /// 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 /// Add a new global terminal tab (not tied to any session).