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
6 changes: 6 additions & 0 deletions .claude/rules/libghostty.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ paths:
`shell-integration-features = no-cursor,no-title`. `no-cursor` prevents prompt DECSCUSR bar resets;
`no-title` prevents abbreviated local cwd OSC 2 from overriding sidebar names. User/remote OSC titles
still work, and OSC 7 is unaffected.
- `ssh-env` and `ssh-terminfo` are forced OFF after `ghostty_config_load_recursive_files`, so no user
source including a `config-file` include can enable them: their wrappers call a `ghostty` CLI agterm
does not bundle, and enabling either broke `ssh` outright (#463). The override reads the resolved
packed bits back and restates all six flags, because ghostty re-parses the key from its defaults on
every occurrence. Setting either is a silent no-op with no diagnostic: a report that it has no effect
is by design, while a report that it still installs an `ssh` wrapper or breaks `ssh` is a regression.
- A one-shot local OSC 2 is cleared by the next prompt. Hold the shell with
`printf '\033]2;X\007'; cat` to test; SSH works because it blocks the local prompt cycle.
- `liveFocus` is key window and first responder. The key gate is essential because AppKit retains one
Expand Down
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ windows/
__pycache__/
.ruff_cache/

# revmux review scaffolding: caller-written task context plus its run archive, local to a review
.revmux/
# revmux review scaffolding: caller-written task context plus its run archive, local to a review.
# profile.md is the exception — it calibrates every review of this repo, so it is tracked. Excluding
# the directory itself would make that re-include unreachable, hence the glob.
.revmux/*
!.revmux/profile.md

# track the path-scoped Claude rules (the global ~/.gitignore_global ignores .claude/)
!.claude/
Expand Down
59 changes: 59 additions & 0 deletions .revmux/profile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# agterm

## What it is

A native macOS terminal, SwiftUI over libghostty, with a workspace-to-session sidebar and a full
control API driven by the bundled `agtermctl` over a local unix socket. Shipped as a signed and
notarized `.app` on Homebrew, plus a static site at agterm.com. One maintainer, no on-call.

Two modules. `agtermCore` is a host-free SwiftPM package under Swift 6 complete concurrency checking
with no Xcode, GhosttyKit, AppKit, Metal or CoreGraphics dependency, holding the model, persistence,
parsing, validation, routing, response shaping and static catalogs. The app target is the side-effect
adapter: the SwiftUI shell, the AppKit sidebar, and the C boundary to libghostty.

## What a real failure looks like here

A user's terminal session becomes unusable or is lost: a pane that stops painting, a session that
restores without its shell environment, a spawned command that never runs, a control command that
lands on the wrong session, a crash at the libghostty C boundary. Persisted window and session state
being corrupted is the closest thing to data loss.

libghostty is called through a C boundary from an `@unchecked Sendable` callbacks type, not a
main-actor one, so use-after-free and cross-thread access there are real and have shipped before.

## Blast radius

One user per install, recoverable by relaunch. No server, no customer data, nothing irreversible.
A bad release reaches everyone on Homebrew until the next one, which raises the bar for anything in
the launch, restore or surface-lifecycle paths specifically.

## Reporting bar

Severity follows user-visible consequence: critical for data loss or a broken primary path, major for
wrong results or a broken secondary path, minor otherwise. Documentation inaccuracies are never
critical or major.

macOS is the only platform, so POSIX portability is not a finding on its own, and a shellcheck SC3xxx
on a shipped script is a lint gate rather than a runtime defect.

## Deliberate conventions, not defects

- Comments and docs are kept short on purpose. Only non-obvious constraints, rejected alternatives, or
why the obvious implementation fails. Narrating code, restating a fact owned elsewhere, or a doc
comment longer than the body it documents are all defects in the other direction.
- Test comments are rare and one line. No arrange/act/assert labels, no restating an assertion.
- Private by default. Exported only for an out-of-package caller.
- Interfaces are defined on the consumer side; the app target accepts them and returns concrete types.
- SwiftLint runs strict with zero findings required: 200-column lines, 1000-line files, 800-line types,
raised to 2000 for tests. Disabled and tuned rules are deliberate.
- `agterm/Resources/ghostty`, `agterm/Resources/terminfo` and `GhosttyKit.xcframework` are gitignored
build artifacts staged from upstream ghostty at a pinned revision, not project source.
- `cookbook/` recipes are third-party work the project publishes but does not own.

## What the project keeps in sync

A new user action is incomplete until the control protocol, the dispatcher, `agtermctl` and the
protocol tests all carry it, and a state-setting command must expose its result on the control tree.
`site/docs.html` is the canonical user guide, `site/commands.html` the canonical command reference,
and the bundled agent skill under `plugins/agterm/skills/agterm/` is the source for installed copies.
A change to the control API, the keymap or the model that updates only some of those is a real finding.
25 changes: 25 additions & 0 deletions agterm/Ghostty/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,30 @@ final class GhosttyApp {
return cfg
}

/// Their ssh wrappers require a `ghostty` CLI absent from agterm's bundle.
private static let unsupportedShellFeatures: Set<String> = ["ssh-env", "ssh-terminfo"]

/// Applies the override after recursive includes so no user config can re-enable these features.
private func forceUnsupportedShellFeaturesOff(_ cfg: ghostty_config_t) {
let key = "shell-integration-features"
var bits: UInt32 = 0
guard key.withCString({ ghostty_config_get(cfg, &bits, $0, UInt(key.utf8.count)) }) else {
logger.warning("could not read \(key, privacy: .public); leaving ssh shell features as configured")
return
}
let value = ShellIntegrationFeatures.overrideValue(resolvedBits: bits,
disabled: Self.unsupportedShellFeatures)
let tmp = (NSTemporaryDirectory() as NSString).appendingPathComponent("agterm-sif-\(UUID().uuidString).conf")
do {
try "shell-integration-features = \(value)\n".write(toFile: tmp, atomically: true, encoding: .utf8)
} catch {
logger.warning("shell-integration-features override write failed: \(error.localizedDescription, privacy: .public)")
return
}
tmp.withCString { ghostty_config_load_file(cfg, $0) }
try? FileManager.default.removeItem(atPath: tmp)
}

private func loadConfig(_ inputs: ConfigInputs, extraOverlayPath: String? = nil) -> ghostty_config_t? {
guard let cfg = ghostty_config_new() else { return nil }

Expand Down Expand Up @@ -559,6 +583,7 @@ final class GhosttyApp {
}

ghostty_config_load_recursive_files(cfg)
forceUnsupportedShellFeaturesOff(cfg)
ghostty_config_finalize(cfg)

let diagCount = ghostty_config_diagnostics_count(cfg)
Expand Down
4 changes: 4 additions & 0 deletions agterm/SettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@ final class SettingsModel {
#
# NOTE: agterm's UI-managed keys (font, theme, background opacity/blur, scroll speed) are set
# in Settings and always win over this file — set those in Settings, everything else here.
#
# NOT SUPPORTED: the `ssh-env` and `ssh-terminfo` shell-integration features. They work by
# wrapping `ssh` as a call to the `ghostty` CLI absent from agterm's bundle,
# so agterm forces them back off. Your other shell-integration-features flags are kept.

"""
}
Expand Down
22 changes: 22 additions & 0 deletions agtermCore/Sources/agtermCore/ShellIntegrationFeatures.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

/// Renders an explicit `shell-integration-features` line from libghostty's resolved packed bits, so
/// ghostty stays the only thing that parses config text.
public enum ShellIntegrationFeatures {
/// Flag names in the field order of libghostty's `ShellIntegrationFeatures`, so an index here is that
/// flag's bit position. `ShellIntegrationFeatureBitsTests` pins the positions and the count.
static let ordered = ["cursor", "sudo", "title", "ssh-env", "ssh-terminfo", "path"]

/// Every flag spelled out from `resolvedBits`, with `disabled` forced off. Naming all of them is the
/// point: ghostty re-parses this key from its defaults, so an omitted flag is reset, not left alone.
public static func overrideValue(resolvedBits: UInt32, disabled: Set<String>) -> String {
ordered.enumerated().map { index, name in
let on = bits(resolvedBits, hasFlagAt: index) && !disabled.contains(name)
return on ? name : "no-\(name)"
}.joined(separator: ",")
}

static func bits(_ value: UInt32, hasFlagAt index: Int) -> Bool {
value & (1 << UInt32(index)) != 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation
import Testing
@testable import agtermCore

struct ShellIntegrationFeaturesTests {
private let sshFlags: Set<String> = ["ssh-env", "ssh-terminfo"]

/// cursor | title | path, ghostty's own defaults for this struct.
private let defaultBits: UInt32 = 0b100101

@Test func defaultsRenderWithBothSSHFlagsOff() {
#expect(ShellIntegrationFeatures.overrideValue(resolvedBits: defaultBits, disabled: sshFlags)
== "cursor,no-sudo,title,no-ssh-env,no-ssh-terminfo,path")
}

@Test func aResolvedSSHFlagIsForcedOffWhileEveryOtherFlagSurvives() {
// defaults with no-cursor and ssh-terminfo, the reporter's config in #463
let bits: UInt32 = 0b110100
#expect(ShellIntegrationFeatures.overrideValue(resolvedBits: bits, disabled: sshFlags)
== "no-cursor,no-sudo,title,no-ssh-env,no-ssh-terminfo,path")
}

@Test func everyFlagOnStillLosesOnlyTheSSHPair() {
#expect(ShellIntegrationFeatures.overrideValue(resolvedBits: 0b111111, disabled: sshFlags)
== "cursor,sudo,title,no-ssh-env,no-ssh-terminfo,path")
}

@Test func everyFlagOffStaysOff() {
#expect(ShellIntegrationFeatures.overrideValue(resolvedBits: 0, disabled: sshFlags)
== "no-cursor,no-sudo,no-title,no-ssh-env,no-ssh-terminfo,no-path")
}

@Test func nothingDisabledLeavesAResolvedSSHFlagOn() {
#expect(ShellIntegrationFeatures.overrideValue(resolvedBits: defaultBits | (1 << 4), disabled: [])
== "cursor,no-sudo,title,no-ssh-env,ssh-terminfo,path")
}

@Test func flagOrderMatchesLibghosttysFieldOrder() {
#expect(ShellIntegrationFeatures.ordered == ["cursor", "sudo", "title", "ssh-env", "ssh-terminfo", "path"])
}
}
64 changes: 64 additions & 0 deletions agtermTests/ShellIntegrationFeatureBitsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import GhosttyKit
import XCTest
@testable import agterm
@testable import agtermCore

/// Pins the Swift flag mapping and count against the bundled libghostty.
@MainActor
final class ShellIntegrationFeatureBitsTests: XCTestCase {
private func resolvedBits(_ cfg: ghostty_config_t) -> UInt32? {
let key = "shell-integration-features"
var bits: UInt32 = 0
let got = key.withCString { ghostty_config_get(cfg, &bits, $0, UInt(key.utf8.count)) }
return got ? bits : nil
}

func testDefaultBitsMatchTheKnownLayout() throws {
let cfg = try XCTUnwrap(ghostty_config_new())
defer { ghostty_config_free(cfg) }
ghostty_config_finalize(cfg)

// ghostty's own defaults: cursor, title and path on; sudo and both ssh flags off.
XCTAssertEqual(resolvedBits(cfg), 0b100101)
}

// catches a trailing upstream field the per-name mapping test cannot see
func testBoolTrueCoversExactlyTheFlagsWeKnowAbout() throws {
let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let file = dir.appendingPathComponent("all.conf")
try "shell-integration-features = true\n".write(to: file, atomically: true, encoding: .utf8)

let cfg = try XCTUnwrap(ghostty_config_new())
defer { ghostty_config_free(cfg) }
file.path.withCString { ghostty_config_load_file(cfg, $0) }
ghostty_config_finalize(cfg)

let known = (UInt32(1) << UInt32(ShellIntegrationFeatures.ordered.count)) - 1
XCTAssertEqual(resolvedBits(cfg), known, "libghostty has a shell-integration feature ShellIntegrationFeatures.ordered does not")
}

func testEachFlagOccupiesTheBitItsNameIsMappedTo() throws {
let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

// each spelling must move only its own bit against ghostty's defaults
let defaults: UInt32 = 0b100101
for (index, name) in ShellIntegrationFeatures.ordered.enumerated() {
let bit = UInt32(1) << UInt32(index)
for (value, expected) in [(name, defaults | bit), ("no-\(name)", defaults & ~bit)] {
let file = dir.appendingPathComponent("\(value).conf")
try "shell-integration-features = \(value)\n".write(to: file, atomically: true, encoding: .utf8)

let cfg = try XCTUnwrap(ghostty_config_new())
defer { ghostty_config_free(cfg) }
file.path.withCString { ghostty_config_load_file(cfg, $0) }
ghostty_config_finalize(cfg)

XCTAssertEqual(resolvedBits(cfg), expected, "\(value) does not move bit \(index) alone")
}
}
}
}
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ A reload applies most keys to your open terminals right away — colors, theme,
- **Layout keys** — `window-padding-x`, `window-padding-y`, and other size-affecting keys — do not re-apply to an open pane. libghostty re-derives a surface's padding only when it is first laid out, so a reload (and even resizing the window) leaves existing panes on their old padding. Open a new session or new window to pick up the change; the panes that were already open need a relaunch.
- **Spawn-time keys** — `term` and `shell-integration-features` — are read once when the shell starts, so a reload cannot change them for a shell that is already running. Open a new session, whose shell is spawned fresh, to apply them.

The full ghostty key reference is at <https://ghostty.org/docs/config>.
The full ghostty key reference is at <https://ghostty.org/docs/config>. One pair of values in it does not apply to agterm: the `ssh-env` and `ssh-terminfo` values of `shell-integration-features`. Ghostty implements both by replacing your `ssh` with a wrapper that calls the `ghostty` command-line tool absent from agterm's bundle, so in agterm the wrapper would fail on every connection. agterm forces those two values back off and keeps the rest of your `shell-integration-features` flags, so `ssh` stays the real `ssh`. If you need agterm's terminfo entry on a remote host, install it there once with `infocmp -x xterm-ghostty | ssh <host> 'tic -x -'`.

## Copy/paste and shortcuts on a non-Latin or alternative layout

Expand Down
7 changes: 6 additions & 1 deletion plugins/agterm/skills/agterm/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ You are inside agterm (`AGTERM_ENABLED=1`). Use:
global Ghostty config is on. agterm's Settings (font/theme/opacity/scroll) still win. Use it for keys the UI does not expose, e.g.
`macos-option-as-alt`. Most keys apply to open panes on reload, but layout keys (`window-padding-*`)
and spawn-time keys (`term`, `shell-integration-features`) only take effect in a new session/window
or after a relaunch. Full reference: https://ghostty.org/docs/config
or after a relaunch. Full reference: https://ghostty.org/docs/config. Two values in it do NOT apply:
`ssh-env` and `ssh-terminfo` for `shell-integration-features`. Ghostty implements them by replacing
`ssh` with a wrapper calling a `ghostty` CLI absent from agterm's bundle, so agterm forces both off
after reading the config and keeps every other flag. Setting either is by design a no-op, reports no
diagnostic, and is NOT a bug. For remote terminfo, install the entry manually with
`infocmp -x xterm-ghostty | ssh <host> 'tic -x -'`.
- **Logs** (unified logging, subsystem `com.umputun.agterm`):
```bash
log show --predicate 'subsystem == "com.umputun.agterm"' --info --last 30m
Expand Down
11 changes: 11 additions & 0 deletions site/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,17 @@
agterm manages from Settings (font, theme, opacity, blur, scroll speed) still win. A common use:
<span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">macos-option-as-alt = true</span>. The
full key reference is at <a href="https://ghostty.org/docs/config" style="color: #6d82f3">ghostty.org/docs/config</a>.
Two values in it do not apply here:
<span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">ssh-env</span> and
<span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">ssh-terminfo</span> for
<span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">shell-integration-features</span>.
Ghostty implements both by replacing your
<span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">ssh</span> with a wrapper that calls
the <span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">ghostty</span> command-line tool
absent from agterm's bundle, so the wrapper would fail on every connection. agterm forces those two
back off and keeps the rest of your flags, leaving
<span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">ssh</span> the real
<span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">ssh</span>.
</p>
<p style="font-size: 15px; line-height: 1.65; color: #949ba4; margin: 16px 0 0">
A <span style="font-family: &quot;JetBrains Mono&quot;, monospace; color: #cbc6bc">keybind</span> here follows ghostty's
Expand Down
Loading