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
7 changes: 6 additions & 1 deletion agterm/Commands/CustomCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@ final class CustomCommandRunner {
/// How long a half-typed leader sequence waits for its next chord before abandoning (kitty-style).
private static let leaderTimeout: TimeInterval = 1.5

init(library: WindowLibrary, settings: SettingsModel, actions: AppActions,
/// Run counts behind the title-bar popover's most-used section; every spawn path records into it.
let usage: CustomCommandUsageStore

init(library: WindowLibrary, settings: SettingsModel, actions: AppActions, usage: CustomCommandUsageStore,
socketProvider: @escaping () -> String) {
self.library = library
self.settings = settings
self.actions = actions
self.usage = usage
self.socketProvider = socketProvider
}

Expand Down Expand Up @@ -377,6 +381,7 @@ final class CustomCommandRunner {
}
do {
try process.run()
usage.record(command)
} catch {
logger.error("custom command \"\(name, privacy: .public)\" failed to spawn: \(error.localizedDescription, privacy: .public)")
NotificationManager.shared.notifyCommandFailure(name: name, detail: error.localizedDescription)
Expand Down
38 changes: 29 additions & 9 deletions agterm/Views/WindowContentView+CustomCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import AppKit
import SwiftUI

/// Title-bar custom-commands button and its popover, the mouse form of the ⌃⇧O palette (#570): every
/// `keymap.conf` command as a clickable row with its chord, in file order so a row never moves under a
/// learned mouse position. Split out of `WindowContentView` like `+RecentSessions`, whose button it mirrors.
/// `keymap.conf` command as a clickable row with its chord, the most-run ones grouped on top. Both groups
/// keep file order; usage changes only which commands sit in the top group. Split out of
/// `WindowContentView` like `+RecentSessions`, whose button it mirrors.
extension WindowContentView {
/// How many most-run commands lead the list, and the command count above which that section appears.
static let mostUsedCommandLimit = 5

/// Title-bar button opening the custom-commands popover. Disabled/dimmed with no parsed command or no
/// active session: the runner ignores a command fired without one, so every row would silently no-op.
/// Opening a popover is interactive-only, so it is control-API keep-in-sync exempt like the clock and bell.
Expand Down Expand Up @@ -37,14 +41,28 @@ extension WindowContentView {
}
}

/// The popover body: the commands in keymap order, in a list that scrolls past a cap since the keymap
/// has none, as wide as its longest row between a floor and the recent-sessions popover's width. Tinted
/// like that popover.
/// The popover body: the most-run commands (only once the keymap holds more than the limit) above a
/// separator, then the rest, in a list that scrolls past a cap since the keymap has none, as wide as its
/// longest row between a floor and the recent-sessions popover's width. Counts decide only which rows
/// lead; both groups keep keymap order, so usage changes which commands sit on top and nothing else.
/// Tinted like the recent-sessions popover. The counts are read on every open, so runs from a chord or
/// the palette count too.
private func customCommandsPopover(_ commands: [CustomCommand]) -> some View {
let metrics = GhosttyApp.shared.interfaceMetrics
let mostUsed = commands.count > Self.mostUsedCommandLimit
? actions.customCommandRunner?.usage.load().mostUsed(of: commands, limit: Self.mostUsedCommandLimit) ?? []
: []
let leadingIDs = Set(mostUsed.map(\.id))
let leading = commands.filter { leadingIDs.contains($0.id) }
let rest = commands.filter { !leadingIDs.contains($0.id) }
return ScrollView {
VStack(spacing: 2) {
ForEach(commands) { customCommandRow($0) }
ForEach(leading) { customCommandRow($0, accessibilityID: "custom-command-top-row") }
if !leading.isEmpty {
Rectangle().fill(chromeText.opacity(0.25)).frame(height: 1)
.padding(.horizontal, 8).padding(.vertical, 4)
}
ForEach(rest) { customCommandRow($0, accessibilityID: "custom-command-row") }
}
.padding(6)
}
Expand All @@ -54,9 +72,10 @@ extension WindowContentView {
.presentationBackground(terminalColor)
}

private func customCommandRow(_ command: CustomCommand) -> some View {
private func customCommandRow(_ command: CustomCommand, accessibilityID: String) -> some View {
CustomCommandPopoverRow(title: command.name, shortcut: command.shortcut.isEmpty ? nil : command.shortcut,
foreground: chromeText, hoverColor: popoverHoverColor) { runFromPopover(command) }
foreground: chromeText, hoverColor: popoverHoverColor,
accessibilityID: accessibilityID) { runFromPopover(command) }
}

/// Commit a row click: note activity (so auto-follow can't pull the selection away), run the command
Expand All @@ -78,6 +97,7 @@ private struct CustomCommandPopoverRow: View {
let shortcut: String?
let foreground: Color
let hoverColor: Color
let accessibilityID: String
let onSelect: () -> Void
@State private var hovering = false
private let metrics = GhosttyApp.shared.interfaceMetrics
Expand Down Expand Up @@ -128,6 +148,6 @@ private struct CustomCommandPopoverRow: View {
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.accessibilityIdentifier("custom-command-row")
.accessibilityIdentifier(accessibilityID)
}
}
1 change: 1 addition & 0 deletions agterm/agtermApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ struct agtermApp: App {
// server's bound socket path for `{AGT_SOCKET}`.
_customCommandRunner = State(initialValue: CustomCommandRunner(
library: library, settings: settingsModel, actions: actions,
usage: CustomCommandUsageStore(directory: stateDirectory),
socketProvider: { controlServer.resolvedSocketPath }))
// follows macOS light/dark via KVO on NSApp.effectiveAppearance; dependency-free, started in `.task`.
_appearanceObserver = State(initialValue: SystemAppearanceObserver())
Expand Down
73 changes: 73 additions & 0 deletions agtermCore/Sources/agtermCore/CustomCommandUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation

/// Run counts for `keymap.conf` commands, keyed by name, backing the most-used section of the title-bar
/// custom-commands popover. The name is the durable key: `CustomCommand.id` is minted on every parse, and
/// the parser rejects a second `command` line with a name already taken.
public struct CustomCommandUsage: Codable, Equatable, Sendable {
public static let currentVersion = 1

public var version: Int
public var counts: [String: Int]

public init(version: Int = CustomCommandUsage.currentVersion, counts: [String: Int] = [:]) {
self.version = version
self.counts = counts
}

/// Count one run of `command`.
public mutating func record(_ command: CustomCommand) {
counts[command.name, default: 0] += 1
}

/// The most-run of `commands`, at most `limit`, in descending count with ties kept in `commands` order.
/// A command never run is left out, and a count whose command is no longer in the keymap takes no slot.
public func mostUsed(of commands: [CustomCommand], limit: Int) -> [CustomCommand] {
commands.enumerated()
.compactMap { index, command -> (count: Int, index: Int, command: CustomCommand)? in
guard let count = counts[command.name], count > 0 else { return nil }
return (count, index, command)
}
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.index < $1.index }
.prefix(max(0, limit))
.map(\.command)
}
}

/// On-disk home of `CustomCommandUsage`: `<stateDir>/custom-command-usage.json`, read tolerantly (a missing,
/// corrupt or foreign-version file counts as empty) and written atomically, like `RecentClosedStore`.
public struct CustomCommandUsageStore: Sendable {
private let directory: URL
private let fileName: String

private var fileURL: URL { directory.appendingPathComponent(fileName) }

public init(directory: URL, fileName: String = "custom-command-usage.json") {
self.directory = directory
self.fileName = fileName
}

public func load() -> CustomCommandUsage {
guard let data = try? Data(contentsOf: fileURL),
let usage = try? JSONDecoder().decode(CustomCommandUsage.self, from: data),
usage.version == CustomCommandUsage.currentVersion else { return CustomCommandUsage() }
return usage
}

/// Count one run of `command` and persist the result.
public func record(_ command: CustomCommand) {
var usage = load()
usage.record(command)
save(usage)
}

private func save(_ usage: CustomCommandUsage) {
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(usage).write(to: fileURL, options: .atomic)
} catch {
NSLog("agterm: save custom command usage failed: %@", String(describing: error))
}
}
}
14 changes: 11 additions & 3 deletions agtermCore/Sources/agtermCore/Keymap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -710,9 +710,10 @@ private func splitMapAlternatives(_ parsed: Alternatives, line: Int,
return (menuChord, alternatives)
}

/// Parse the remainder of a `command` line (after the verb): `"<name>" [chord] <shell...>`. On any failure
/// it appends a diagnostic and leaves `commandLines` untouched. The kept alternatives ride alongside the
/// command; `applySurvivingShortcuts` is what turns them back into `CustomCommand.shortcut`.
/// Parse the remainder of a `command` line (after the verb): `"<name>" [chord] <shell...>`. On any failure,
/// a name already taken included, it appends a diagnostic and leaves `commandLines` untouched. The kept
/// alternatives ride alongside the command; `applySurvivingShortcuts` is what turns them back into
/// `CustomCommand.shortcut`.
private func parseCommandLine(_ rest: String, line: Int, commandLines: inout [ParsedCommandLine],
diagnostics: inout [KeymapDiagnostic]) {
guard rest.first == "\"", let closeQuote = rest.dropFirst().firstIndex(of: "\"") else {
Expand Down Expand Up @@ -755,6 +756,13 @@ private func parseCommandLine(_ rest: String, line: Int, commandLines: inout [Pa
return
}

// the name is the identity a run count is stored under, so a second definition cannot share it.
guard !commandLines.contains(where: { $0.command.name == name }) else {
diagnostics.append(KeymapDiagnostic(line: line,
message: "command '\(name)' is already defined; command skipped"))
return
}

commandLines.append(ParsedCommandLine(command: CustomCommand(name: name, command: shellLine, shortcut: ""),
alternatives: kept))
}
58 changes: 58 additions & 0 deletions agtermCore/Tests/agtermCoreTests/CustomCommandUsageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation
import Testing
@testable import agtermCore

struct CustomCommandUsageTests {
private let finder = CustomCommand(name: "Finder", command: "open .", shortcut: "cmd+shift+f")
private let lazygit = CustomCommand(name: "Lazygit", command: "lazygit", shortcut: "")
private let cheatsheet = CustomCommand(name: "Cheatsheet", command: "less keys.md", shortcut: "ctrl+a>k")

@Test func recordCountsRunsByName() {
var usage = CustomCommandUsage()
usage.record(finder)
usage.record(finder)
usage.record(lazygit)
#expect(usage.counts == ["Finder": 2, "Lazygit": 1])
}

@Test func mostUsedOrdersByCountThenKeymapOrderAndSkipsNeverRun() {
var usage = CustomCommandUsage()
usage.record(cheatsheet)
usage.record(lazygit)
usage.record(finder)
usage.record(cheatsheet)
let commands = [finder, lazygit, cheatsheet]
#expect(usage.mostUsed(of: commands, limit: 5).map(\.name) == ["Cheatsheet", "Finder", "Lazygit"])
#expect(usage.mostUsed(of: commands, limit: 2).map(\.name) == ["Cheatsheet", "Finder"])
#expect(usage.mostUsed(of: [lazygit, finder], limit: 5).map(\.name) == ["Lazygit", "Finder"])
#expect(usage.mostUsed(of: commands, limit: 0).isEmpty)
#expect(CustomCommandUsage().mostUsed(of: commands, limit: 5).isEmpty)
}

@Test func mostUsedIgnoresCountsForCommandsNoLongerInTheKeymap() {
var usage = CustomCommandUsage(counts: ["Gone": 9, "Zero": 0])
usage.record(finder)
let zero = CustomCommand(name: "Zero", command: "true", shortcut: "")
#expect(usage.mostUsed(of: [zero, finder], limit: 5).map(\.name) == ["Finder"])
}

@Test func storeRoundTripsAndReadsMissingOrForeignFilesAsEmpty() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("custom-command-usage-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appendingPathComponent("custom-command-usage.json")
let store = CustomCommandUsageStore(directory: directory)
#expect(store.load() == CustomCommandUsage())

store.record(finder)
store.record(finder)
#expect(store.load() == CustomCommandUsage(counts: ["Finder": 2]))

let foreign = CustomCommandUsage(version: CustomCommandUsage.currentVersion + 1, counts: ["x": 3])
try JSONEncoder().encode(foreign).write(to: file)
#expect(store.load() == CustomCommandUsage())

try Data("not json".utf8).write(to: file)
#expect(store.load() == CustomCommandUsage())
}
}
12 changes: 12 additions & 0 deletions agtermCore/Tests/agtermCoreTests/KeymapTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,18 @@ struct KeymapTests {
#expect(command.command == "./deploy.sh")
}

@Test func parseCommandDuplicateNameKeepsTheFirstDefinition() {
let (keymap, diagnostics) = parseKeymap("""
command "Deploy" cmd+shift+d ./deploy.sh
command "Deploy" ./deploy.sh --prod
command "Other" ./other.sh
""")
#expect(keymap.commands.map(\.name) == ["Deploy", "Other"])
#expect(keymap.commands[0].command == "./deploy.sh")
#expect(keymap.commands[0].shortcut == "cmd+shift+d")
#expect(diagnostics == [KeymapDiagnostic(line: 2, message: "command 'Deploy' is already defined; command skipped")])
}

@Test func parseCommandBareKeyRejectedAsShortcut() {
// a bare key would shadow that key in the terminal, so it is never consumed as a shortcut.
let (keymap, diagnostics) = parseKeymap("command \"X\" a echo hi")
Expand Down
2 changes: 1 addition & 1 deletion agtermTests/CustomCommandRunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ final class CustomCommandRunnerTests: XCTestCase {
let actions = AppActions(library: library)
actions.settingsModel = settings
let runner = CustomCommandRunner(library: library, settings: settings, actions: actions,
socketProvider: { "" })
usage: CustomCommandUsageStore(directory: stateDir), socketProvider: { "" })
runner.start()
started.append(runner)
let store = try XCTUnwrap(library.activeStore)
Expand Down
4 changes: 3 additions & 1 deletion agtermTests/FullScreenChordTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ final class FullScreenChordTests: XCTestCase {
settings: SettingsModel(library: library,
settingsStore: SettingsStore(directory: stateDir)),
actions: AppActions(library: library),
usage: CustomCommandUsageStore(directory: stateDir),
socketProvider: { "" })
// `NSWindow` defaults isReleasedWhenClosed to true; see the hosted-test rule in ui-tests.md.
window = RecordingWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
Expand Down Expand Up @@ -84,7 +85,8 @@ final class FullScreenChordTests: XCTestCase {
settings.setConfigDirectory(configDir.path)
let actions = AppActions(library: library)
actions.settingsModel = settings
return CustomCommandRunner(library: library, settings: settings, actions: actions, socketProvider: { "" })
return CustomCommandRunner(library: library, settings: settings, actions: actions,
usage: CustomCommandUsageStore(directory: stateDir), socketProvider: { "" })
}

func testShippedChordTogglesFullScreenAndIsConsumed() throws {
Expand Down
Loading
Loading