• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / agterm / 30285977016

27 Jul 2026 04:43PM UTC coverage: 97.884% (-0.02%) from 97.9%
30285977016

push

github

web-flow
feat(control): add keymap.list, the read side of keymap.reload (#301)

Add keymap.list, returning the resolved built-in actions alongside the live
menu-bar key equivalents so the two can be compared directly when they disagree.
Actions report overridden when a chord differs from the shipped default and list
keyless actions too; menu items recurse into submenus and carry enabled: false
when the item is disabled and its chord therefore inert. Also returns path,
commands, and diagnostics with line and message rather than a bare count.

Add namedKey(forKeyEquivalent:), the character twin of namedKey(forKeyCode:), so
both lists render in the same kitty syntax and equal bindings produce equal
strings. AppKit reports arrows and return as function-key and control characters,
which would otherwise print a modifier with the key missing; the range is pinned
against bindableNamedKeys and the globe modifier prints as fn+. The projection is
host-free in ControlKeymap, only the live menu read is app-side.

Catalog count 67 to 68, mirrored in the agent skill, site/commands.html,
site/docs.html, README, and both troubleshooting guides.

93 of 96 new or added lines in 6 files covered. (96.88%)

6244 of 6379 relevant lines covered (97.88%)

4713295.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.26
/agtermCore/Sources/agtermctlKit/SocketClient.swift
1
#if canImport(Darwin)
2
import Darwin
3
#elseif canImport(Glibc)
4
import Glibc
5
#endif
6
import Foundation
7
import agtermCore
8

9
/// A failure talking to the control socket (connect/write/read/decode), distinct from a server-side
10
/// `{"ok":false}` response (which is a valid decoded `ControlResponse`).
11
struct SocketClientError: Error, CustomStringConvertible {
12
    let description: String
13
    init(_ description: String) { self.description = description }
3✔
14
}
15

16
/// A blocking, one-request-per-connection client for the agterm control socket: connect to a unix domain
17
/// socket, write the request line, read the single response line, decode it.
18
struct SocketClient {
19
    let path: String
20

21
    /// 64 MiB cap on a response line. Requests stay small; a `session.text --all` response carries the
22
    /// whole scrollback and can reach several MiB. It comes from our own server, so the cap only guards
23
    /// against a runaway read (ghostty scrollback tops out near 10 MiB).
24
    private static let maxLineBytes = 64 << 20
25

26
    /// Connect, send `request` as one newline-terminated JSON line, read the response line, decode it.
27
    func send(_ request: ControlRequest) throws -> ControlResponse {
17✔
28
        let fd = try connect()
17✔
29
        defer { close(fd) }
14✔
30

14✔
31
        var data = try JSONEncoder().encode(request)
14✔
32
        data.append(UInt8(ascii: "\n"))
14✔
33
        try Self.writeAll(fd, data)
14✔
34

14✔
35
        guard let line = Self.readLine(fd) else {
14✔
36
            throw SocketClientError("no response from \(path)")
×
37
        }
14✔
38
        do {
14✔
39
            return try JSONDecoder().decode(ControlResponse.self, from: line)
14✔
40
        } catch {
14✔
41
            throw SocketClientError("could not decode response: \(error.localizedDescription)")
×
42
        }
×
43
    }
14✔
44

45
    /// Open and connect a `AF_UNIX` stream socket to `path`.
46
    private func connect() throws -> Int32 {
17✔
47
        var addr = sockaddr_un()
17✔
48
        let pathCapacity = MemoryLayout.size(ofValue: addr.sun_path)
17✔
49
        guard path.utf8.count < pathCapacity else {
17✔
50
            throw SocketClientError("socket path too long (\(path.utf8.count) bytes): \(path)")
1✔
51
        }
16✔
52
        #if canImport(Darwin)
53
        let fd = socket(AF_UNIX, SOCK_STREAM, 0)
16✔
54
        #else
55
        let fd = socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0)
56
        #endif
57
        guard fd >= 0 else { throw SocketClientError("socket() failed: \(String(cString: strerror(errno)))") }
16✔
58

16✔
59
        addr.sun_family = sa_family_t(AF_UNIX)
16✔
60
        let pathBytes = path.utf8CString
16✔
61
        withUnsafeMutablePointer(to: &addr.sun_path) { dst in
16✔
62
            dst.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { buf in
16✔
63
                pathBytes.withUnsafeBufferPointer { src in
16✔
64
                    buf.update(from: src.baseAddress!, count: src.count)
16✔
65
                }
16✔
66
            }
16✔
67
        }
16✔
68

16✔
69
        let result = withUnsafePointer(to: &addr) { ptr in
16✔
70
            ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
16✔
71
                systemConnect(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size))
16✔
72
            }
16✔
73
        }
16✔
74
        guard result == 0 else {
16✔
75
            let message = String(cString: strerror(errno))
2✔
76
            close(fd)
2✔
77
            throw SocketClientError("connect(\(path)) failed: \(message) — is agterm running?")
2✔
78
        }
14✔
79
        return fd
14✔
80
    }
17✔
81

82
    /// Write all of `data` to `fd`, looping over short writes.
83
    private static func writeAll(_ fd: Int32, _ data: Data) throws {
14✔
84
        try data.withUnsafeBytes { raw in
14✔
85
            var offset = 0
14✔
86
            let base = raw.bindMemory(to: UInt8.self).baseAddress!
14✔
87
            while offset < data.count {
28✔
88
                let n = write(fd, base + offset, data.count - offset)
14✔
89
                if n <= 0 { throw SocketClientError("write failed: \(String(cString: strerror(errno)))") }
14✔
90
                offset += n
14✔
91
            }
14✔
92
        }
14✔
93
    }
14✔
94

95
    /// Read up to (and excluding) the first newline, capping at `maxLineBytes`. Returns nil on
96
    /// EOF-before-newline, error, or cap exceeded. Reads in 64 KiB chunks so a multi-MB
97
    /// `session.text --all` response takes a handful of syscalls instead of one per byte.
98
    private static func readLine(_ fd: Int32) -> Data? {
14✔
99
        var buffer = Data()
14✔
100
        var chunk = [UInt8](repeating: 0, count: 64 * 1024)
14✔
101
        while true {
429✔
102
            let n = chunk.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) }
429✔
103
            if n == 0 { return buffer.isEmpty ? nil : buffer }
429✔
104
            if n < 0 { return nil }
429✔
105
            if let idx = chunk[0..<n].firstIndex(of: UInt8(ascii: "\n")) {
429✔
106
                buffer.append(contentsOf: chunk[0..<idx])
14✔
107
                return buffer
14✔
108
            }
415✔
109
            buffer.append(contentsOf: chunk[0..<n])
415✔
110
            if buffer.count > maxLineBytes { return nil }
415✔
111
        }
415✔
112
    }
×
113

114
    /// Print a response: the raw JSON line with `json: true`, otherwise a human-readable summary. An error
115
    /// response (`ok == false`, non-`--json`) goes to stderr; everything else to stdout.
116
    static func printResponse(_ response: ControlResponse, json: Bool, echoID: Bool = false) {
4✔
117
        if !json, !response.ok {
4✔
118
            FileHandle.standardError.write(Data((formatResponse(response, json: false) + "\n").utf8))
1✔
119
            return
1✔
120
        }
3✔
121
        print(formatResponse(response, json: json, echoID: echoID))
3✔
122
    }
3✔
123

124
    /// Render a response to a single string (no trailing newline): the raw JSON line with `json: true`,
125
    /// otherwise a human-readable summary — an `error:` line, the tree listing, the selected text, the
126
    /// affected id (only when `echoID`, i.e. for the create commands), or a bare `ok`. Pure so it can be
127
    /// unit-tested directly; `printResponse` routes it to stdout/stderr.
128
    static func formatResponse(_ response: ControlResponse, json: Bool, echoID: Bool = false) -> String {
27✔
129
        if json {
27✔
130
            if let data = try? JSONEncoder().encode(response), let line = String(data: data, encoding: .utf8) {
2✔
131
                return line
2✔
132
            }
2✔
133
            return ""
×
134
        }
25✔
135
        if !response.ok {
25✔
136
            return "error: " + (response.error ?? "unknown error")
3✔
137
        }
22✔
138
        if let tree = response.result?.tree {
22✔
139
            return formatTree(tree)
3✔
140
        }
19✔
141
        if let windows = response.result?.windows {
19✔
142
            return formatWindows(windows)
2✔
143
        }
17✔
144
        if let themes = response.result?.themes {
17✔
145
            return formatThemes(themes, current: response.result?.theme, sync: response.result?.sync ?? false,
3✔
146
                                light: response.result?.light, dark: response.result?.dark)
3✔
147
        }
14✔
148
        if let keymap = response.result?.keymap {
14✔
149
            return formatKeymap(keymap)
1✔
150
        }
13✔
151
        if let text = response.result?.text {
13✔
152
            return text
1✔
153
        }
12✔
154
        if let exitCode = response.result?.exitCode {
12✔
155
            return "exit \(exitCode)"
×
156
        }
12✔
157
        if let affected = response.result?.affected {
12✔
158
            return affected == 1 ? "1 session" : "\(affected) sessions"
3✔
159
        }
9✔
160
        if let count = response.result?.count {
9✔
161
            // keymap.reload reports its parse-diagnostic count; 0 reads as a clean reload.
2✔
162
            return count == 0 ? "ok" : "\(count) diagnostic(s)"
2✔
163
        }
7✔
164
        if let ratio = response.result?.ratio {
7✔
165
            // session.resize echoes the applied (clamped) left-pane fraction, scriptable as a bare number.
1✔
166
            return String(format: "%.3f", ratio)
1✔
167
        }
6✔
168
        if echoID, let id = response.result?.id {
6✔
169
            return id
2✔
170
        }
4✔
171
        return "ok"
4✔
172
    }
27✔
173

174
    /// Render the `theme.list` payload as one theme name per line, the active theme(s) marked with `* `
175
    /// and a leading "default ghostty" entry for the no-theme (ghostty built-in) case (no trailing newline).
176
    /// When `sync` is on, both the light and dark themes are marked and a header notes the appearance pair;
177
    /// otherwise the single `current` theme is marked.
178
    static func formatThemes(_ themes: [String], current: String?, sync: Bool = false,
179
                             light: String? = nil, dark: String? = nil) -> String {
3✔
180
        let active: (String?) -> Bool = sync ? { $0 != nil && ($0 == light || $0 == dark) } : { $0 == current }
7✔
181
        func line(_ name: String?, _ label: String) -> String { (active(name) ? "* " : "  ") + label }
9✔
182
        let body = ([line(nil, "default ghostty")] + themes.map { line($0, $0) }).joined(separator: "\n")
6✔
183
        guard sync else { return body }
3✔
184
        let header = "syncing with macOS appearance — light: \(light ?? "default ghostty"), dark: \(dark ?? "default ghostty")"
1✔
185
        return header + "\n" + body
1✔
186
    }
3✔
187

188
    /// Render the `keymap.list` payload as sections: the resolved built-ins, then custom commands, parse
189
    /// diagnostics, and the live menu key equivalents (no trailing newline). An overridden built-in is
190
    /// marked `*`, and a keyless one prints `-` rather than being dropped, so the listing is the full
191
    /// action set.
192
    ///
193
    /// The menu section is the point of the command: comparing it against the actions above is what shows
194
    /// a chord the keymap resolved but the menu is not carrying. Menu items are printed in menu-bar order.
195
    static func formatKeymap(_ keymap: ControlKeymap) -> String {
5✔
196
        var lines = ["keymap: \(keymap.path)", "", "actions:"]
5✔
197
        let width = keymap.actions.map(\.action.count).max() ?? 0
210✔
198
        for action in keymap.actions {
210✔
199
            let mark = action.overridden == true ? "*" : " "
210✔
200
            let name = action.action.padding(toLength: max(width, action.action.count), withPad: " ", startingAt: 0)
210✔
201
            lines.append("  \(mark) \(name)  \(action.chord ?? "-")")
210✔
202
        }
210✔
203
        if !keymap.commands.isEmpty {
5✔
NEW
204
            lines.append(contentsOf: ["", "commands:"])
×
NEW
205
            lines.append(contentsOf: keymap.commands.map { "    \($0.name)  \($0.shortcut ?? "(palette only)")" })
×
NEW
206
        }
×
207
        if !keymap.diagnostics.isEmpty {
5✔
208
            lines.append(contentsOf: ["", "diagnostics:"])
2✔
209
            // line 0 is the whole-file / cross-section sentinel, not a real line — drop the number
2✔
210
            // rather than sending the reader looking for it, matching SettingsView.diagnosticLine.
2✔
211
            lines.append(contentsOf: keymap.diagnostics.map {
3✔
212
                $0.line > 0 ? "    line \($0.line): \($0.message)" : "    \($0.message)"
3✔
213
            })
3✔
214
        }
2✔
215
        if let menu = keymap.menu {
5✔
216
            lines.append(contentsOf: ["", "menu:"])
2✔
217
            // mark a disabled item: its chord is inert (AppKit consumes the key and fires nothing, not
2✔
218
            // even a same-chord sibling), and the default non-JSON output is the documented human
2✔
219
            // workflow — an unmarked row reads as a live binding.
2✔
220
            lines.append(contentsOf: menu.map {
3✔
221
                "    \($0.chord)  \($0.menu) ▸ \($0.title)" + ($0.enabled == false ? "  (disabled)" : "")
3✔
222
            })
3✔
223
        }
2✔
224
        return lines.joined(separator: "\n")
5✔
225
    }
5✔
226

227
    /// Render the `window.list` payload as one `id  name  [open]  [active]` line per window (no trailing
228
    /// newline). Closed/inactive windows still list, with the bracket tag absent.
229
    static func formatWindows(_ windows: [ControlWindowNode]) -> String {
2✔
230
        windows.map { window in
4✔
231
            let tags = (window.open ? " [open]" : "") + (window.active ? " [active]" : "")
4✔
232
            return "\(window.id)  \(window.name)\(tags)"
4✔
233
        }.joined(separator: "\n")
4✔
234
    }
2✔
235

236
    /// Render a tree as an indented workspace → session listing (no trailing newline).
237
    private static func formatTree(_ tree: ControlTree) -> String {
3✔
238
        var lines: [String] = []
3✔
239
        for workspace in tree.workspaces {
3✔
240
            let mark = workspace.active ? "*" : " "
3✔
241
            lines.append("\(mark) \(workspace.name)  [\(workspace.id)]")
3✔
242
            for session in workspace.sessions {
3✔
243
                let smark = session.active ? "*" : " "
3✔
244
                let tags = (session.split ? " (split)" : "") + (session.overlay ? " (overlay)" : "")
3✔
245
                    + (session.scratch ? " (scratch)" : "")
3✔
246
                let titleSuffix = session.title.map { "  title: \($0)" } ?? ""
3✔
247
                lines.append("  \(smark) \(session.name)\(tags)  [\(session.id)]  \(session.cwd)\(titleSuffix)")
3✔
248
            }
3✔
249
        }
3✔
250
        return lines.joined(separator: "\n")
3✔
251
    }
3✔
252
}
253

254
private func systemConnect(_ fd: Int32, _ addr: UnsafePointer<sockaddr>, _ len: socklen_t) -> Int32 {
16✔
255
    #if canImport(Darwin)
256
    return Darwin.connect(fd, addr, len)
16✔
257
    #else
258
    return Glibc.connect(fd, addr, len)
259
    #endif
260
}
16✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc