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
45 changes: 41 additions & 4 deletions Sources/Simpleton/AI/AIKeychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ enum AIKeychain {

private static let service = "com.simpleton.ai"

/// Process-lifetime cache of retrieved keys, so the secret is read from the Keychain at most once
/// per provider per launch. Reading the data (`retrieveAPIKey`) is the only call that can raise
/// the macOS "allow access" prompt, and it fires on every AI-preferences open (autoLoadModels) and
/// every request. Without this cache the user is prompted repeatedly; with it, once the key is in
/// memory (from the first read, a save, or the last launch's "Always Allow"), no further reads —
/// and no further prompts — happen for the rest of the session.
private static var memoryCache: [String: String] = [:]
private static let cacheLock = NSLock()
private static func cacheStore(_ key: String, account: String) {
cacheLock.lock()
memoryCache[account] = key
cacheLock.unlock()
}
private static func cacheLookup(_ account: String) -> String? {
cacheLock.lock()
defer { cacheLock.unlock() }
return memoryCache[account]
}
private static func cacheClear(_ account: String) {
cacheLock.lock()
memoryCache[account] = nil
cacheLock.unlock()
}

static func storeAPIKey(_ key: String, for provider: AIProvider) -> Bool {
let account = "apiKey.\(provider.rawValue)"
guard let data = key.data(using: .utf8) else { return false }
Expand All @@ -26,18 +50,28 @@ enum AIKeychain {
// subject to that owner check, so overwrites succeed reliably. (See TN3137.)
let updateStatus = SecItemUpdate(
query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if updateStatus == errSecSuccess { return true }
if updateStatus == errSecSuccess {
cacheStore(key, account: account)
return true
}
if updateStatus == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
if SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess {
cacheStore(key, account: account)
return true
}
return false
}
return false
}

static func retrieveAPIKey(for provider: AIProvider) -> String? {
let account = "apiKey.\(provider.rawValue)"
// Serve from the in-memory cache first — this is what turns "prompt every time AI prefs open"
// into "prompt at most once per launch" (and none at all once a grant persists).
if let cached = cacheLookup(account) { return cached }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
Expand All @@ -47,9 +81,11 @@ enum AIKeychain {
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data
let data = result as? Data,
let key = String(data: data, encoding: .utf8)
else { return nil }
return String(data: data, encoding: .utf8)
cacheStore(key, account: account)
return key
}

static func deleteAPIKey(for provider: AIProvider) {
Expand All @@ -60,6 +96,7 @@ enum AIKeychain {
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
cacheClear(account)
}

/// Migrate an existing key to AfterFirstUnlock accessibility — at most once per provider.
Expand Down
14 changes: 14 additions & 0 deletions Sources/Simpleton/AI/TabConversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ final class TabConversation: ObservableObject {
self.splitController = splitController
self.aiService = aiService
rebuildPaneLabels()
// Screenshot demo mode: seed a short sample conversation so the AI panel shows a real exchange
// in captures (no live provider call needed). Gated by env; off in normal use.
if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil {
messages = [
ChatMessage(
role: "user", content: "What's listening on port 8080 and how do I free it?"),
ChatMessage(
role: "assistant",
content:
"`lsof -i :8080` shows **node** (PID 588) holding it. Free it with `kill -9 588`, "
+ "or start your dev server elsewhere: `PORT=8081 npm run dev`. Want me to run the kill?"
),
]
}
}

// MARK: - Pane Labels
Expand Down
59 changes: 55 additions & 4 deletions Sources/Simpleton/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
self.panelRegistry = panelRegistry

// Screenshot harness: `--shot-profile <name>` activates a panel profile at launch (the active
// profile is otherwise in-memory only and resets to General each launch). No-op without it.
if let i = CommandLine.arguments.firstIndex(of: "--shot-profile"),
i + 1 < CommandLine.arguments.count,
var profile = panelRegistry.profiles.first(where: { $0.name == CommandLine.arguments[i + 1] })
{
// `--shot-active-panel <id>` starts that left panel active (its onAppear auto-loads the
// content) so each panel can be captured with a plain relaunch — no runtime AX clicking.
if let j = CommandLine.arguments.firstIndex(of: "--shot-active-panel"),
j + 1 < CommandLine.arguments.count
{
profile.leftActivePanelID = CommandLine.arguments[j + 1]
}
panelRegistry.activeProfile = profile
}

// Fire startup event
pluginManager?.fireEvent(
.onStartup,
Expand Down Expand Up @@ -475,6 +491,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
wc.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)

maybeLaunchFullScreenForScreenshots(wc.window)

// Focus the terminal in the new window's active tab.
if let tabContainer = wc.tabManager.activeContainer {
wc.window?.makeFirstResponder(
Expand All @@ -484,6 +502,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {

}

/// Screenshot harness only: when launched with `--shot-fullscreen`, pin the window to the built-in
/// Retina display and enter native fullscreen. Driving fullscreen in-process is reliable (over
/// Accessibility it is not, and on a multi-display setup the window otherwise opens on a secondary
/// display's inactive Space where it can't be captured cleanly). No-op without the flag.
private func maybeLaunchFullScreenForScreenshots(_ window: NSWindow?) {
guard CommandLine.arguments.contains("--shot-fullscreen"), let window else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
if let builtin = NSScreen.screens.first(where: { screen in
(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID)
.map { CGDisplayIsBuiltin($0) != 0 } ?? false
}) {
window.setFrame(builtin.frame, display: true)
}
if !window.styleMask.contains(.fullScreen) {
window.toggleFullScreen(nil)
}
}
}

@objc private func windowClosed(_ notification: Notification) {
guard let wc = notification.object as? WindowController else { return }
windowControllers.removeAll { $0 === wc }
Expand All @@ -503,6 +540,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
activeWindowController?.tabManager.activeContainer?.splitController
}

/// The primary terminal window used to anchor floating panels (command palette, quick connect).
/// Prefer the app's main window — it stays the terminal even when a nonactivating panel is key —
/// then any ordered terminal window. Never returns a panel, so a panel is never parented to a panel
/// (which would hide the second panel the moment the first one dismisses). A Simpleton terminal
/// window is identified by `activeTabContainer != nil` (its content VC is a swappable host, not the
/// container itself, so `contentViewController is TabContainerController` no longer holds).
private var terminalWindow: NSWindow? {
if let main = NSApp.mainWindow, main.activeTabContainer != nil {
return main
}
let candidates = NSApp.orderedWindows + windowControllers.compactMap { $0.window }
return candidates.first { $0.activeTabContainer != nil }
}

// MARK: - Config

private func loadConfig() {
Expand Down Expand Up @@ -572,9 +623,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
quickConnectPanel?.dismiss()
return
}
// Capture the terminal window BEFORE showing the panel, because the panel
// becomes key and NSApp.keyWindow would then point to the panel itself.
let parentWindow = NSApp.keyWindow
// Anchor to the real terminal window — never whatever is key (a command palette could be key
// here), so the panel parents to the terminal and positions over it instead of over a panel.
let parentWindow = terminalWindow
// Reuse the existing panel instance; do NOT recreate it here.
quickConnectPanel?.show(relativeTo: parentWindow) { [weak self] bookmark in
self?.connectToBookmark(bookmark, in: parentWindow)
Expand All @@ -589,7 +640,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return
}
let actions = buildPaletteActions()
commandPalettePanel?.show(relativeTo: NSApp.keyWindow, actions: actions)
commandPalettePanel?.show(relativeTo: terminalWindow, actions: actions)
}

private func buildPaletteActions() -> [PaletteAction] {
Expand Down
1 change: 1 addition & 0 deletions Sources/Simpleton/Panels/ActivityBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct ActivityBarView: View {
) {
togglePanel(id: panelID)
}
.accessibilityIdentifier("panel-\(panelID)")
.onDrag { NSItemProvider(object: panelID as NSString) }
}
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/Simpleton/Panels/FileBrowserPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ struct FileBrowserPanelView: View {
}

private func syncCWD() {
// Screenshot demo mode: browse a fixed demo project instead of the user's real home/cwd
// (which would leak personal folders). Gated by env; off in normal use.
if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil {
currentURL = URL(fileURLWithPath: "/tmp/simpleton-demo-fs/webapp")
loadEntries()
return
}
if let cwd = currentPaneProvider()?.currentDirectory {
currentURL = URL(fileURLWithPath: cwd)
}
Expand Down
14 changes: 14 additions & 0 deletions Sources/Simpleton/Panels/ProcessesPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ struct ProcessesPanelView: View {
}

private func fetchProcesses() async -> [ProcessEntry] {
// Screenshot demo mode: a fixed, fake process list instead of the machine's real processes
// (which would leak what apps the user runs). Gated by env; off in normal use.
if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil {
return [
ProcessEntry(id: 412, pid: 412, cpu: 2.4, mem: 1.1, command: "zsh"),
ProcessEntry(id: 588, pid: 588, cpu: 8.1, mem: 3.4, command: "node"),
ProcessEntry(id: 731, pid: 731, cpu: 0.6, mem: 2.2, command: "docker"),
ProcessEntry(id: 902, pid: 902, cpu: 1.3, mem: 0.9, command: "postgres"),
ProcessEntry(id: 1044, pid: 1044, cpu: 0.2, mem: 0.5, command: "ssh"),
ProcessEntry(id: 1210, pid: 1210, cpu: 12.5, mem: 6.7, command: "Simpleton"),
ProcessEntry(id: 1355, pid: 1355, cpu: 0.1, mem: 0.3, command: "git"),
ProcessEntry(id: 1489, pid: 1489, cpu: 3.0, mem: 1.8, command: "nvim"),
]
}
// Use -U to filter to current user directly, avoiding per-process sysctl calls
let currentUser = NSUserName()
// Run the process + waitUntilExit off the MainActor so a slow `ps` can't
Expand Down
11 changes: 11 additions & 0 deletions Sources/Simpleton/Panels/SSHTunnelsPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ struct SSHTunnelsPanelView: View {
}

private func loadTunnels() {
// Screenshot demo mode: show sample active tunnels (real sessions here are local, so the
// panel would otherwise be empty). Gated by env; off in normal use.
if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil {
tunnels = [
TunnelEntry(localPort: 8080, remoteHost: "localhost", remotePort: 80, paneName: "prod-web"),
TunnelEntry(localPort: 5432, remoteHost: "db.internal", remotePort: 5432, paneName: "db-primary"),
TunnelEntry(
localPort: 9090, remoteHost: "grafana.internal", remotePort: 3000, paneName: "staging-api"),
]
return
}
guard let panes = tabContainerProvider()?.splitController.panes else {
tunnels = []
return
Expand Down
25 changes: 21 additions & 4 deletions Sources/Simpleton/Views/AIChatPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,27 @@ struct AIChatPanelView: View {
// the conversation the source of truth and preventing one tab's messages from leaking
// into another.
.onAppear {
guard let conv = conversation else { return }
messages = conv.messages
agentBubbles = conv.agentBubbles
isStreaming = conv.isRunning
if let conv = conversation {
messages = conv.messages
agentBubbles = conv.agentBubbles
isStreaming = conv.isRunning
}
// Screenshot demo mode: seed a sample exchange at the display layer so the AI panel shows
// a real conversation in captures (no live provider call). Runs even if no conversation is
// attached yet. Gated by env; off in normal use.
if messages.isEmpty, ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil {
messages = [
ChatMessage(
role: "user", content: "What's listening on port 8080 and how do I free it?"),
ChatMessage(
role: "assistant",
content:
"`lsof -i :8080` shows **node** (PID 588) holding it. Free it with `kill -9 588`, "
+ "or start your dev server elsewhere: `PORT=8081 npm run dev`. Want me to run the kill?"
),
]
conversation?.messages = messages
}
}
.onChange(of: messages) { _, v in conversation?.messages = v }
.onChange(of: agentBubbles.count) { _, _ in conversation?.agentBubbles = agentBubbles }
Expand Down
14 changes: 13 additions & 1 deletion Sources/Simpleton/Views/CommandPalettePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ final class CommandPalettePanel {
defer: false
)
newPanel.isFloatingPanel = true
newPanel.level = .floating
// Normal level (not .floating): the panel is attached as a child of the terminal window in
// show(), so it lives in the parent's z-order band. A .floating level would keep it above
// every app — even when the terminal window is sent behind another app (the reported bug).
newPanel.level = .normal
newPanel.titleVisibility = .hidden
newPanel.titlebarAppearsTransparent = true
newPanel.backgroundColor = .clear
Expand Down Expand Up @@ -70,6 +73,9 @@ final class CommandPalettePanel {
// key window, so window-targeting commands (Split, New Tab, Close Pane, Toggle
// Sidebar) resolved no active window and silently no-op'd — only the connection
// commands, which open their own panels, appeared to work.
if let panel = self?.panel, let parent = panel.parent {
parent.removeChildWindow(panel)
}
self?.panel?.orderOut(nil)
self?.panel?.alphaValue = 1
self?.parentWindow?.makeKey()
Expand All @@ -84,6 +90,9 @@ final class CommandPalettePanel {
if let window = window {
let windowFrame = window.frame
panel.setFrameOrigin(NSPoint(x: windowFrame.midX - 260, y: windowFrame.midY + 50))
// Attach to the terminal window so the panel tracks its z-order: sent behind another app
// with the parent, never floating on top of everything.
if panel.parent == nil { window.addChildWindow(panel, ordered: .above) }
} else {
panel.center()
}
Expand All @@ -102,6 +111,9 @@ final class CommandPalettePanel {
panel.animator().alphaValue = 0
},
completionHandler: { [weak panel] in
if let panel = panel, let parent = panel.parent {
parent.removeChildWindow(panel)
}
panel?.orderOut(nil)
panel?.alphaValue = 1
})
Expand Down
11 changes: 10 additions & 1 deletion Sources/Simpleton/Views/QuickConnectPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ final class QuickConnectPanel {
defer: false
)
newPanel.isFloatingPanel = true
newPanel.level = .floating
// Normal level (not .floating): the panel is attached as a child of the terminal window in
// show(), so it lives in the parent's z-order band. A .floating level would keep it above
// every app — even when the terminal window is sent behind another app (the reported bug).
newPanel.level = .normal
newPanel.titleVisibility = .hidden
newPanel.titlebarAppearsTransparent = true
newPanel.isMovableByWindowBackground = false
Expand Down Expand Up @@ -73,6 +76,9 @@ final class QuickConnectPanel {
let x = windowFrame.midX - 260
let y = windowFrame.midY + 50
panel.setFrameOrigin(NSPoint(x: x, y: y))
// Attach to the terminal window so the panel tracks its z-order: sent behind another app
// with the parent, never floating on top of everything.
if panel.parent == nil { window.addChildWindow(panel, ordered: .above) }
} else {
panel.center()
}
Expand All @@ -91,6 +97,9 @@ final class QuickConnectPanel {
panel.animator().alphaValue = 0
},
completionHandler: { [weak panel] in
if let panel = panel, let parent = panel.parent {
parent.removeChildWindow(panel)
}
panel?.orderOut(nil)
panel?.alphaValue = 1
})
Expand Down
1 change: 1 addition & 0 deletions Sources/Simpleton/Views/SkillsPreferencesTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ struct SkillsPreferencesTab: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("skill-\(skill.name)")
}

private func newSkill() {
Expand Down
Loading
Loading