// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import GhosttyTerminal import TermindApplication import TermindDomain import TermindPresentation import Testing @testable import Termind @Suite @MainActor struct CommandNotificationTests { @Test(arguments: ["hidden", "navigation", "snippets", "history", "peer", "archive", "visible"]) func assistantNotificationOpensSidebarWithoutReplacingWorkspace(_ state: String) throws { let app = AppModel(hosts: HostLibraryModel()) let window = WindowModel() app.registerWindow(window) let conversation = ConversationModel() conversation.composer.draft = "Target" conversation.sendDraft() window.assistantLibrary.insertAndSelect(conversation) let workspace = Workspace(pane: Pane(.empty)) workspace.splitFocusedPane(axis: .horizontal, with: Pane(.empty)) window.open(workspace) let focusedPane = workspace.focusedPane window.showAssistant() switch state { case "hidden": window.showsSidebar = false case "navigation": window.sidebarMode = .navigation case "snippets": window.sidebarMode = .snippets case "history": window.sidebarMode = .history case "peer": let peer = ConversationModel() peer.composer.draft = "Other conversation" peer.sendDraft() window.assistantLibrary.insertAndSelect(peer) case "archive": window.showAssistantArchiveInSidebar() default: break } #expect(window.isShowingAssistant(conversation.id) == (state == "visible")) #expect(app.isAssistantNotificationFocused(conversation.id) == (state == "visible")) window.isQuickOpenPresented = true #expect(app.selectAssistantNotificationTarget(conversation.id) === window) #expect(window.selectedWorkspace === workspace) #expect(workspace.focusedPane === focusedPane) #expect(window.showsSidebar && window.sidebarMode == .assistant) #expect(window.isShowingAssistant(conversation.id)) #expect(!window.isAssistantArchivePresented) #expect(!window.isQuickOpenPresented && !window.isToolbarFindFocused) } @Test(arguments: [true, false]) func assistantNotificationPrefersFocusThenStableOrderWithinMatchingWindows(_ visible: Bool) throws { let app = AppModel(hosts: HostLibraryModel()) let windows = [WindowModel(), WindowModel()].sorted { $0.id.uuidString < $1.id.uuidString } for window in windows { app.registerWindow(window) } let conversation = ConversationModel() conversation.composer.draft = "Target" conversation.sendDraft() windows[0].assistantLibrary.insertAndSelect(conversation) for window in windows { window.openAssistantFullScreen(conversation.id) if !visible { window.selectVault() } } #expect( app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === windows[1] }) === windows[1]) if !visible { windows[1].selectVault() } #expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { _ in false }) === windows[0]) windows[0].closeForWindow() #expect(app.selectAssistantNotificationTarget(conversation.id) === windows[1]) } @Test func assistantNotificationReusesHiddenSelectionBeforeAnUnrelatedFocusedWindow() throws { let app = AppModel(hosts: HostLibraryModel()) let owner = WindowModel() let other = WindowModel() app.registerWindow(owner) app.registerWindow(other) let conversation = ConversationModel() conversation.composer.draft = "Target" conversation.sendDraft() owner.assistantLibrary.insertAndSelect(conversation) owner.openAssistantFullScreen(conversation.id) let workspace = Workspace(pane: Pane(.empty)) owner.open(workspace) owner.showsSidebar = false #expect(!app.isAssistantNotificationFocused(conversation.id)) #expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === other }) === owner) #expect(owner.selectedWorkspace === workspace) #expect(owner.isShowingAssistant(conversation.id)) #expect(other.assistantLibrary.selectedConversationID == nil) other.openAssistantFullScreen(conversation.id) owner.showsSidebar = false #expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === owner }) === other) #expect(!owner.showsSidebar) } @Test func notificationRouterUsesTheLatestClickAndConsumesItOnce() async throws { let app = AppModel(hosts: HostLibraryModel()) let window = WindowModel() app.registerWindow(window) let first = ConversationModel() let second = ConversationModel() first.composer.draft = "First" second.composer.draft = "Second" first.sendDraft() window.assistantLibrary.insertAndSelect(first) second.sendDraft() window.assistantLibrary.insertAndSelect(second) let activations = AsyncStream.makeStream() var count = 0 let router = DesktopNotificationRouter(app: app) { _ in count += 1 activations.continuation.yield(()) } router.open(.assistant(first.id)) router.open(.assistant(second.id)) var iterator = activations.stream.makeAsyncIterator() _ = await iterator.next() #expect(window.assistantLibrary.selectedConversationID == second.id) router.resume() #expect(count == 1) router.stop() router.open(.assistant(first.id)) router.resume() #expect(window.assistantLibrary.selectedConversationID == second.id) } @Test func manuallyUnreadWorkspacesCountOnceWithoutRequiringATerminal() { let app = AppModel(hosts: HostLibraryModel()) let window = WindowModel() app.registerWindow(window) let workspace = Workspace(pane: Pane(.empty)) window.open(workspace, select: false) workspace.markCommandsUnread() workspace.markCommandsUnread() #expect(workspace.hasUnreadCommandCompletion) #expect(window.unreadCompletionCount == 1) #expect(app.unreadNotificationCount == 1) workspace.markCommandsRead() #expect(app.unreadNotificationCount == 0) #expect(!Workspace(pane: Pane(.empty)).hasUnreadCommandCompletion) } @Test func appOwnsCommandNotificationsWithoutChangingThemeOrFontPrecedence() { let state = TerminalViewState(configSource: .none) let preferences = TerminalPreferences( darkCustomEnabled: true, darkCustomConfiguration: "notify-on-command-finish = always\nbackground = 010203") state.applyPreferences(preferences) #expect(state.controller.terminalConfiguration.rendered.contains("notify-on-command-finish = never")) #expect(!state.theme.dark.rendered.contains("notify-on-command-finish = always")) #expect(state.theme.dark.rendered.hasSuffix("background = 010203")) } @Test func workspaceFocusIncludesEveryPaneAndTargetsFollowMoves() throws { let host = Host(name: "Build", host: "test.invalid") let app = AppModel(hosts: HostLibraryModel(hosts: [host])) let window = WindowModel() let other = WindowModel() app.registerWindow(window) app.registerWindow(other) let first = Pane.sshSession(host: host) let second = Pane.sshSession(host: host) let workspace = Workspace(pane: first) workspace.splitFocusedPane(axis: .horizontal, with: second) window.open(workspace) let firstID = try #require(first.commandSessionID) let secondID = try #require(second.commandSessionID) #expect(workspace.focusedPane === second) #expect(app.isCommandWorkspaceFocused(firstID, windowIsFocused: { $0 === window })) #expect(app.isCommandWorkspaceFocused(secondID, windowIsFocused: { $0 === window })) #expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in false })) window.selectVault() #expect(app.selectCommandNotificationTarget(firstID) === window) #expect(window.selectedWorkspace === workspace) #expect(workspace.focusedPane === first) window.selectVault() #expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in true })) let monitor = try #require(first.commandMonitor) monitor.beginConnection() monitor.receiveOSC133(exitCode: 0, durationNanos: 0) #expect(workspace.hasUnreadCommandCompletion) #expect(window.unreadCompletionCount == 1) #expect(other.unreadCompletionCount == 0) let moved = try #require(window.takePaneForMove(first.id)) other.open(moved) #expect(!workspace.hasUnreadCommandCompletion) #expect(moved.hasUnreadCommandCompletion) #expect(window.unreadCompletionCount == 0) #expect(other.unreadCompletionCount == 1) #expect(app.commandNotificationTarget(firstID)?.0 === other) other.selectVault() #expect(app.selectCommandNotificationTarget(firstID) === other) #expect(other.selectedWorkspace === moved) #expect(moved.focusedPane === first) moved.markCommandsRead() #expect(!moved.hasUnreadCommandCompletion) #expect(other.unreadCompletionCount == 0) other.close(moved.id) #expect(app.commandNotificationTarget(firstID) == nil) #expect(app.selectCommandNotificationTarget(firstID) == nil) app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: other) #expect(app.commandNotificationTarget(firstID) == nil) #expect(first.commandSessionID != firstID) } @Test func assistantFocusIncludesVisibleSidebarAndNotificationTargetsReuseTheConversation() throws { let app = AppModel(hosts: HostLibraryModel()) let window = WindowModel() let other = WindowModel() app.registerWindow(window) app.registerWindow(other) let conversation = ConversationModel() conversation.composer.draft = "Test" conversation.sendDraft() window.assistantLibrary.insertAndSelect(conversation) window.openAssistantFullScreen(conversation.id) #expect(app.isAssistantNotificationFocused(conversation.id)) #expect(app.isAssistantNotificationFocused(UUID())) window.open(Workspace(pane: Pane(.empty))) window.sidebarMode = .assistant window.showsSidebar = true #expect(window.isShowingAssistant(conversation.id)) window.showsSidebar = false #expect(!window.isShowingAssistant(conversation.id)) #expect(!app.isAssistantNotificationFocused(conversation.id)) other.openAssistantFullScreen(conversation.id) #expect(app.selectAssistantNotificationTarget(conversation.id) === other) #expect(other.assistantLibrary.selectedConversation === conversation) let peer = ConversationModel() peer.composer.draft = "Second conversation" peer.sendDraft() window.assistantLibrary.insertAndSelect(peer) window.openAssistantInSidebar(peer.id) window.showsSidebar = true other.open(Workspace(pane: Pane(.empty))) other.sidebarMode = .assistant other.showsSidebar = true #expect(app.isAssistantNotificationFocused(peer.id)) #expect(app.isAssistantNotificationFocused(conversation.id)) #expect(!app.isAssistantNotificationFocused(UUID())) #expect(other.assistantLibrary.conversation(id: peer.id) === peer) other.isAssistantArchivePresented = true #expect(!other.isShowingAssistant(conversation.id)) let target = try #require(app.selectAssistantNotificationTarget(UUID())) #expect(target.selectedDestination == .assistant) #expect(target.assistantLibrary.selectedConversation == nil) } @Test func ghosttyValuesAlreadyPublishedAreNotReplayedWhenObserving() async { let state = TerminalViewState(configSource: .none) state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1) try? await Task.sleep(for: .milliseconds(30)) let monitor = TerminalCommandMonitor() monitor.beginConnection() let observation = TerminalCommandObservation(state: state, monitor: monitor) #expect(!monitor.hasUnreadCompletion) monitor.endConnection() state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1) try? await Task.sleep(for: .milliseconds(30)) #expect(!monitor.hasUnreadCompletion) withExtendedLifetime(observation) {} } }