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
40 changes: 27 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ which launchd never sized — so a session's buffers cannot jetsam the daemon.
- **The app never spawns processes.** `ighostvtd-io` forks the shells
(`forkpty`+`execve`), and on the Mac the three tools its update needs
(`ditto`, `xattr`, `open`; `MacUpdater`); `ighostvtd` spawns only its two
children — `ighostvtd-io` (`IOSupervisor`) and, while remote access is
on, `ighostvtd-remote` (`RemoteSupervisor`) — via `posix_spawn`. Peer
children — `ighostvtd-io` (`IOSupervisor`) and `ighostvtd-remote`
(`RemoteSupervisor`), which runs while remote access is on, and dormant
(`--dormant`: no listener, no Bonjour, no relay; gone after a minute
unasked) while it is off and the app asks about the paired devices, so
turning the switch off stops them connecting and forgets nobody — the
devices stay listed and removable — via `posix_spawn`. Peer
authentication is still the daemon's, gated by the kernel audit token
before a byte is forwarded. Keep that boundary; don't add process APIs to
the app or to the proxy.
Expand Down Expand Up @@ -65,7 +69,12 @@ which launchd never sized — so a session's buffers cannot jetsam the daemon.
(`onTextSelectionRequest`) the app's own selection sheet hung off, so the
app has no selection UI of its own; customise the menus through
`touchMenuItems(for:)` / `touchSelectionMenuItems(for:)` on
`LockableTerminalView`, never by bringing a sheet back. Match an item
`LockableTerminalView`, never by bringing a sheet back. Copy as One Line
is `OneLineText.join` (tested by `Tests/OneLineText`): a program that lays
out its own text drops the space where it breaks a row, so a row that
stops short of the terminal's width, or ends padded, joins with a space,
and one that fills it closes up only when the word across the break would
not have fit a row — a cut path or link, not a word boundary. Match an item
there by its identifier (`terminal.copy`, `terminal.paste`, …), never by
its title: since 2.2.2026100701 the titles are localized, and they follow
the user's language only because `Info.plist` sets
Expand Down Expand Up @@ -750,7 +759,8 @@ is cut and reconnects as after any interruption. Both plists say
`RunAtLoad` + `KeepAlive = {SuccessfulExit = false}` + `MachServices`: a
crash restarts, the idle exit stands, the next lookup demand-launches, and
`make check` holds both to it. While remote access is on,
`ighostvtd-remote` keeps one connection open for as long as it runs, so the
`ighostvtd-remote` keeps one connection open for as long as it runs (a
dormant one holds none), so the
daemon is resident exactly as long as the switch is on — the daemon itself
knows nothing about it. `shutdown` stays in the protocol for any client
that wants the same exit sooner.
Expand Down Expand Up @@ -996,10 +1006,13 @@ Tab titles have three sources. The daemon's is the one always there: each
session polls `tcgetpgrp` on its PTY (and re-checks as output drains, rate
limited — the drain sees one check per 64 KiB otherwise),
resolves the foreground process group leader's `proc_name`, and pushes it as
event 102 — also stated in every open/attach reply — so
`TerminalTab.secondaryTitle`, the dim line under the title, is a short stable
name ("zsh", "vim", "grok") no matter how often the program retitles, and a
session that reports nothing is titled by it. Ghostty's shell integration is the
event 102 — also stated in every open/attach reply — and a session that
reports nothing is titled by it. It is never the dim line beside the title
(`TerminalTab.secondaryTitle`): some programs' binary is only a version
number (Claude Code's reads `2.1.304`), so that line is the last component
of the tab's directory (`displayDirectory`, the same one the Live Activity
lists, whose row reads title then that component too), plus the device's
name for a remote tab. Ghostty's shell integration is the
second: the daemon injects it (`ShellIntegration`) and the .deb ships
libghostty's own scripts to `/usr/share/ighostvt/shell-integration`, so the
shell reports OSC 2 (command), OSC 7 (cwd), OSC 133 (prompts) by itself.
Expand Down Expand Up @@ -1139,11 +1152,12 @@ there too, given the touch when the finger held still to 0.6 s, and on an
iPad it opened under a reorder that had not moved yet; it is gone, not
retimed.

A tab that copies the active one — ⌘T, or a menu row naming the active
tab's own device and directory — goes right after it, as a browser's
does; every other new tab — another directory, another device's
terminal, a Shortcut, a URL, a moved session — is appended
(`TabManager.opensBesideActiveTab`).
⌘T copies the active tab — its device, and its directory (a remote tab
names its session to the far daemon and its last reported directory as
the fallback) — and goes right after it, as a browser's does. Every other
new tab — ⌘N (this device's home), a menu row, another device's terminal,
a Shortcut, a URL, a moved session — is appended
(`TabManager.newTab(_:besideActiveTab:)`).

Every `+` is a `NewTabMenu`: the only decision a new terminal has is where
its shell starts, so the control opens a menu of directories instead of a
Expand Down
10 changes: 10 additions & 0 deletions Documents/Releases/1.6.17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
iGhostVT 1.6.17 keeps paired devices listed while Remote Access is off, makes ⌘T on a remote tab open where that tab is, and fixes Copy as One Line at word breaks.

- Turning Remote Access off hid the Authorized Devices list, as though the devices had been forgotten. They were only kept from connecting. The list now stays while the switch is off, and a device can still be removed there. Pair New Device returns when Remote Access is back on.
- ⌘T on a tab running on another device opened a terminal on that device in its home directory. It now opens in the directory the tab is in. ⌘T puts the new tab right after the current one. ⌘N and every other way of opening a tab put it at the end, and ⌘N always starts in this device's home directory.
- Copy as One Line glued words together where a program such as Claude Code had wrapped a line between them, because the space at the break is never drawn. Rows that break between words are now joined with a space. A path or link cut at the edge of the terminal still closes up, CJK text still joins without spaces, and a trailing `\` line continuation is dropped.
- The line beside a tab's title showed the program's name, which for Claude Code is only a version number such as `2.1.304`. It now shows the last part of the tab's directory, plus the device's name for a remote tab. The Live Activity lists each session the same way: its title, then the last part of its directory.
- The recent directories in the new-tab menu show only the folder name, without the full path under it.
- Settings now says that Background Activities keeps your sessions running after you quit the app.

Choose `iphoneos-arm64e` for roothide, `iphoneos-arm64` for a rootless bootstrap, `iGhostVT-1.6.17-macos-notarized.zip` for a Mac — it is signed and notarized, so it opens without clearing quarantine, and updates itself — or `GhostRemote-1.6.17.ipa` for an iPhone or iPad without custom firmware, which your sideloading app signs as it installs it. To run the relay, use the attached `compose.yml`; the image is `ghcr.io/owngoal-dev/ighostvt-relay`, and the digest is in `relay-image.txt`. SHA-256 sums are in `SHA256SUMS`, and the Mac app's in `SHA256SUMS.macos`.
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ harness:
$$(find "$(ROOT_DIR)/Tests/Zmodem" -name '*.swift' | sort) \
-o "$$harness_dir/zmodem" && \
"$$harness_dir/zmodem" && \
xcrun --sdk macosx swiftc -swift-version 5 \
"$(ROOT_DIR)/iGhostVT/Backend/Session/OneLineText.swift" \
$$(find "$(ROOT_DIR)/Tests/OneLineText" -name '*.swift' | sort) \
-o "$$harness_dir/one-line" && \
"$$harness_dir/one-line" && \
$(MAKE) --no-print-directory relay-harness

# The relay end to end (Tests/RelayHarness): the Go server under Relay/ built
Expand Down
3 changes: 3 additions & 0 deletions Shared/Wire/IOWire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ enum IOWire {
/// argument that names it.
static let socketDescriptor: Int32 = 3
static let socketArgument = "--socket-fd"
/// Starts `ighostvtd-remote` with the switch off: it answers about the
/// paired devices and opens nothing to the network.
static let dormantArgument = "--dormant"
static let executableName = "ighostvtd-io"

enum Kind: UInt8 {
Expand Down
118 changes: 118 additions & 0 deletions Tests/OneLineText/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Foundation

// Copy as One Line (iGhostVT/Backend/Session/OneLineText.swift) against
// selections as Ghostty reads them: the first row from where the selection
// began, every other row from column 0 with its gutter, painted padding
// kept, soft-wrapped rows already one line.

var failures: [String] = []

func check(_ text: String, columns: Int?, _ expected: String?, _ description: String) {
let actual = OneLineText.join(text, columns: columns)
if actual == expected {
print(" ok \(description)")
} else {
print(" FAIL \(description): \(actual.map { "\"\($0)\"" } ?? "nil") != \(expected.map { "\"\($0)\"" } ?? "nil")")
failures.append(description)
}
}

print("one-line: rows broken between words")
check(
"Reply with only this, no \n tools: The quick fox \n river bank.",
columns: 80,
"Reply with only this, no tools: The quick fox river bank.",
"a Claude Code prompt keeps the spaces its padding stood for",
)
check(
"The quick brown fox jumps over the\n lazy dog",
columns: 40,
"The quick brown fox jumps over the lazy dog",
"a word that did not fit moved to the next row",
)
check(
"aaaa bbbb cccc ddddd\nffff gggg hhhh iiii\njjjj",
columns: 20,
"aaaa bbbb cccc ddddd ffff gggg hhhh iiii jjjj",
"a word ending exactly at the edge still gets its space",
)
check(
"aaaa bbbb cccc ddddd\nffff gggg hhhh iiii\njjjj",
columns: nil,
"aaaa bbbb cccc ddddd ffff gggg hhhh iiii jjjj",
"the same without the terminal's width",
)
check(
"a short\n line\n another",
columns: 80,
"a short line another",
"rows that end far from the edge are separate lines",
)
check("echo foo \nbar", columns: 80, "echo foo bar", "a space before the break is kept")
check("first\n\n\nsecond", columns: 80, "first second", "blank rows between paragraphs collapse to one space")
check("使用 Claude\n Code 工具", columns: 12, "使用 Claude Code 工具", "a Latin word after CJK text keeps its space")

print("one-line: words split over rows")
check(
"⏺ Bash(cat /Users/admin/Documents/GitHub/ow\n ngoal-dev/iGhostVT/README.md)",
columns: 42,
"⏺ Bash(cat /Users/admin/Documents/GitHub/owngoal-dev/iGhostVT/README.md)",
"a path split at the edge closes up",
)
check(
"Documents/GitHub/ow\n ngoal-dev/README.md)",
columns: 42,
"Documents/GitHub/owngoal-dev/README.md)",
"a selection begun inside the path closes up",
)
check(
"ow\n ngoal-dev/README.md",
columns: 42,
"owngoal-dev/README.md",
"a first row of one word is taken for the tail of a split word",
)
check(
" See https://example.com/a/very/long/path/th\n at/keeps/going?x=1",
columns: 45,
"See https://example.com/a/very/long/path/that/keeps/going?x=1",
"a link split at the edge closes up",
)
check(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\naaaaaaa",
columns: 40,
String(repeating: "a", count: 87),
"a word over three rows closes up",
)
check("这是一段很长的中文文本被折\n行了", columns: 26, "这是一段很长的中文文本被折行了", "CJK text broken mid-sentence closes up")
check(" 这是一段很长的中文文本被\n 折行了", columns: 26, "这是一段很长的中文文本被折行了", "CJK text short of the edge still closes up")

print("one-line: shell continuations")
check(
"docker run \\\n --rm \\\n image",
columns: 80,
"docker run --rm image",
"a trailing backslash is taken out",
)
check("a path\\\nb", columns: 80, "a path\\ b", "a backslash inside a word is left alone")

print("one-line: not one line")
check("one row", columns: 80, nil, "a single row has nothing to join")
check(" \n ", columns: 80, nil, "blank rows have no text")

print("one-line: widths")
let wide: [(Character, Bool)] = [("a", false), ("中", true), ("한", true), ("ア", true), ("😀", true), ("é", false), ("─", false)]
for (character, isWide) in wide {
if OneLineText.isWide(character) == isWide {
print(" ok \(character) is \(isWide ? "wide" : "narrow")")
} else {
print(" FAIL \(character) is \(isWide ? "wide" : "narrow")")
failures.append("width of \(character)")
}
}

if failures.isEmpty {
print("one-line: all passed")
} else {
print("one-line: \(failures.count) failed")
exit(1)
}
6 changes: 5 additions & 1 deletion iGhostVT/Application/AppMenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@ enum AppMenus {
/// duplicates"). The value only has to differ from the twin's `nil`;
/// `TerminalWindow` reads a `propertyList` as an `Int` tab index and
/// ignores this string.
/// What a hidden alias carries, so an action can tell its alias key
/// from its listed one (⌘N from ⌘T).
static let aliasPropertyList = "alias"

private static func hidden(_ action: Selector) -> UIKeyCommand? {
let shortcut = KeyShortcuts.alias(action)
guard shortcut.isEnabled else { return nil }
Expand All @@ -353,7 +357,7 @@ enum AppMenus {
action: action,
input: shortcut.input,
modifierFlags: shortcut.modifiers,
propertyList: "alias",
propertyList: aliasPropertyList,
attributes: .hidden,
)
}
Expand Down
10 changes: 8 additions & 2 deletions iGhostVT/Application/TerminalWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,14 @@ final class TerminalWindow: UIWindow, AppCommandResponder {

// MARK: - File

func newTab(_: Any?) {
_ = tabManager.newTab()
/// ⌘T copies the active tab — its device, its directory — right beside
/// it. ⌘N, its hidden alias, is a fresh local shell at the end.
func newTab(_ sender: Any?) {
if (sender as? UIKeyCommand)?.propertyList as? String == AppMenus.aliasPropertyList {
_ = tabManager.newTab(.home)
} else {
_ = tabManager.newTab(besideActiveTab: true)
}
}

func newWindow(_: Any?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ final class SessionActivityController {
TerminalSessionAttributes.Session(
id: tab.id.uuidString,
title: tab.reportedTitle,
directory: Self.displayPath(of: tab),
directory: tab.displayDirectory,
shell: tab.isRemote ? "" : Self.configuredShellName,
process: tab.store.processName.isEmpty ? nil : tab.store.processName,
number: number,
Expand Down Expand Up @@ -154,13 +154,6 @@ final class SessionActivityController {
}
}

/// Where the widget says the tab is. The daemon's reading first — it is
/// the kernel's, so it is right for a shell that reports no OSC 7 at
/// all — and the shell's own report while the session has not said yet.
private static func displayPath(of tab: TerminalTab) -> String {
tab.currentDirectory?.label ?? TerminalDirectory.abbreviate(tab.terminal.workingDirectory)
}

#if !targetEnvironment(macCatalyst) && canImport(ActivityKit)
/// A quit that ended this app's shells (Background Activities off)
/// takes the activity with it. Nothing else would: the process is
Expand Down
21 changes: 5 additions & 16 deletions iGhostVT/Backend/Session/LockableTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ final class LockableTerminalView: TerminalView {
/// sent while it connects goes nowhere. False when the wait is given up.
var awaitConnection: (@MainActor () async -> Bool)?

/// The terminal's width in columns, nil before the surface reported
/// one. Copy as One Line reads where a row ends against it.
var terminalColumns: (@MainActor () -> Int?)?

/// What the terminal offers UIKit as its keyboard while locked. An empty
/// view keeps first-responder status — and with it the hardware key
/// path — while leaving nothing to raise.
Expand Down Expand Up @@ -421,7 +425,7 @@ final class LockableTerminalView: TerminalView {
override func touchSelectionMenuItems(for context: TerminalTouchSelectionMenuContext) -> [UIMenuElement] {
var items = routingPaste(super.touchSelectionMenuItems(for: context))
var extras: [UIMenuElement] = []
if let line = Self.oneLine(context.selectedText) {
if let line = OneLineText.join(context.selectedText, columns: terminalColumns?()) {
extras.append(UIAction(
title: String(localized: "Copy as One Line"),
image: UIImage(systemName: "arrow.right.to.line"),
Expand All @@ -445,21 +449,6 @@ final class LockableTerminalView: TerminalView {
return items
}

/// The selection with every row trimmed at both ends and the line
/// breaks between them taken out, or nil when it is one row. A
/// program that lays out its own text (Claude Code) breaks a long
/// command or path over rows with no soft-wrap mark, so the plain
/// copy keeps those breaks and a shell reads each row as a command
/// of its own. Nothing is put between the rows: a break inside a
/// path must close up, and where a space was lost the person can
/// see it before running anything.
static func oneLine(_ text: String) -> String? {
let rows = text.split(whereSeparator: \.isNewline)
guard rows.count > 1 else { return nil }
let line = rows.map { $0.trimmingCharacters(in: .whitespaces) }.joined()
return line.isEmpty ? nil : line
}

/// The selection as one http(s) link, or nil. The whitespace around
/// it goes, and a link a program broke over rows — Claude Code
/// prints each row as a line of its own — is put back together,
Expand Down
Loading
Loading