feat(terminal): add inline selection and native menus

This commit is contained in:
Kaede Akino committed 2026-10-02 04:06:18 +08:00
1 parent c98ddd0ffb
commit a8e00e5122
11 files changed
+160 -15

No files matched your search

+4
View File
@@ -4,3 +4,7 @@
[submodule "libssh2-spm"]
path = libssh2-spm
url = https://github.com/AkinoKaede/libssh2-spm.git
[submodule "libghostty-spm"]
path = libghostty-spm
url = https://github.com/AkinoKaede/libghostty-spm.git
branch = termind
+13
View File
@@ -17,6 +17,19 @@ The agent runtime is `AgentKit`, a separate open-source package developed in the
Xcode builds the pinned tag, not the sibling working tree, so an uncommitted change in `../AgentKit` is invisible to the app. A change that spans both sides takes three steps: make and test the package change, tag and push a release from `../AgentKit`, then bump `minimumVersion` in `project.pbxproj` and re-resolve so `Package.resolved` records the new revision. Both files belong in the same commit as the app-side change that needs them.
`libghostty-spm/` is a maintenance submodule of the public
`AkinoKaede/libghostty-spm` fork, tracking `termind`; upstream is
`Lakr233/libghostty-spm`. Xcode consumes the fork as a remote SPM dependency
pinned to an exact commit, not the submodule working tree. Push package changes
to `termind`, then update the submodule gitlink, `project.pbxproj` revision and
`Package.resolved` together. Run `git submodule update --init libghostty-spm`
to obtain the maintenance checkout. Changes to `Patches/ghostty/` require a new
fork XCFramework release and a matching URL/checksum in `Package.swift` before
advancing the app's pin. The package's native iOS touch menus expose
separate subclass hooks, `touchMenuItems(for:)` and
`touchSelectionMenuItems(for:)`, before and after selection. Override them on
the platform view, with grouping and localization owned by Termind.
## Architecture Records
Treat `openwiki/` as durable just-in-time engineering context. Start with `openwiki/quickstart.md` before changing dependencies, navigation ownership, state lifetimes, persistence, agent execution, permissions, secret handling, or release operations, then follow its topic-specific page to current source and tests. Source code and tests remain authoritative. Refresh invalidated OpenWiki content through the OpenWiki lifecycle and keep `ArchitectureBoundaryTests` aligned; do not maintain a parallel refactoring log.
@@ -24823,6 +24823,23 @@
}
}
},
"Select" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "選取"
}
}
}
},
"Select %@ for batch actions" : {
"extractionState" : "manual",
"localizations" : {
@@ -230,6 +230,10 @@ extension AppStrings {
"Search"
}
public static var select: LocalizedStringResource {
"Select"
}
public static var selectAll: LocalizedStringResource {
"Select All"
}
+11 -1
View File
@@ -7,7 +7,17 @@ import UIKit
/// A workspace owns its native surface even while SwiftUI navigates away from it.
@MainActor
final class MobileTerminalSurface {
let view = MobileTerminalView(frame: .zero)
let view: MobileTerminalView = {
let view = MobileTerminalView(frame: .zero)
view.usesInlineTextSelection = true
view.touchSelectionTitles = TerminalTouchSelectionTitles(
paste: String(localized: AppStrings.Common.paste),
select: String(localized: AppStrings.Common.select),
selectAll: String(localized: AppStrings.Common.selectAll),
copy: String(localized: AppStrings.Common.copy)
)
return view
}()
func bindFind(_ find: TerminalFindController) {
view.findController = find
@@ -3,6 +3,7 @@
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindPresentation
/// Mounts one long-lived Ghostty view into whichever pane currently owns it.
///
@@ -194,11 +195,7 @@
if pastes {
// An empty paste must not arm a deferred broadcast ticket that
// could consume the next unrelated mouse or protocol report.
let pasteboard = NSPasteboard.general
guard
pasteboard.string(forType: .string)?.isEmpty == false
|| pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
else { return }
guard canPasteFromClipboard else { return }
captureInteraction { _ = performBindingAction("paste_from_clipboard") }
} else {
super.rightMouseUp(with: event)
@@ -206,6 +203,7 @@
}
override func menu(for event: NSEvent) -> NSMenu? {
guard !isMouseCaptured else { return nil }
if event.type == .rightMouseDown || event.type == .rightMouseUp,
rightClickPastes ?? shouldPasteOnRightClick(event)
{
@@ -214,6 +212,46 @@
return super.menu(for: event)
}
override func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
// The superclass uses this hook to decide whether a right-click
// presents a menu. Offer the app's default menu without a selection too.
isMouseCaptured ? nil : point
}
override func selectionContextMenu() -> NSMenu {
let menu: NSMenu
if super.selectionMenuPoint(at: .zero) != nil {
menu = super.selectionContextMenu()
menu.items.first?.title = String(localized: AppStrings.Common.copy)
} else {
menu = NSMenu()
}
menu.autoenablesItems = false
let pasteItem = NSMenuItem(
title: String(localized: AppStrings.Common.paste),
action: #selector(pasteFromContextMenu(_:)), keyEquivalent: "")
pasteItem.target = self
pasteItem.isEnabled = canPasteFromClipboard
menu.addItem(pasteItem)
let selectAllItem = NSMenuItem(
title: String(localized: AppStrings.Common.selectAll),
action: #selector(selectAll(_:)), keyEquivalent: "")
selectAllItem.target = self
menu.addItem(selectAllItem)
return menu
}
private var canPasteFromClipboard: Bool {
let pasteboard = NSPasteboard.general
return pasteboard.string(forType: .string)?.isEmpty == false
|| pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
}
@objc private func pasteFromContextMenu(_: Any?) {
guard canPasteFromClipboard else { return }
captureInteraction { _ = performBindingAction("paste_from_clipboard") }
}
private func shouldPasteOnRightClick(_ event: NSEvent) -> Bool {
pasteOnRightClick && !isMouseCaptured && !event.modifierFlags.contains(.option)
}
+48
View File
@@ -0,0 +1,48 @@
import AppKit
import TermindPresentation
import Testing
@testable import Termind
@Suite @MainActor
struct TerminalContextMenuTests {
@Test func defaultMenuValidatesPasteAndOptionBypassesAutomaticPaste() throws {
let pasteboard = NSPasteboard.general
let saved = (pasteboard.pasteboardItems ?? []).map { item in
Dictionary(
uniqueKeysWithValues: item.types.compactMap { type in
item.data(forType: type).map { (type, $0) }
})
}
defer {
pasteboard.clearContents()
pasteboard.writeObjects(
saved.map { values in
let item = NSPasteboardItem()
for (type, data) in values { item.setData(data, forType: type) }
return item
})
}
let view = TermindTerminalView(frame: .zero)
let event = try #require(
NSEvent.mouseEvent(
with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 1,
windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
pasteboard.clearContents()
let emptyMenu = try #require(view.menu(for: event))
let pasteTitle = String(localized: AppStrings.Common.paste)
#expect(emptyMenu.item(withTitle: pasteTitle)?.isEnabled == false)
#expect(emptyMenu.items.contains { $0.action == #selector(view.selectAll(_:)) && $0.isEnabled })
#expect(!emptyMenu.items.contains { $0.action == #selector(view.copy(_:)) })
pasteboard.setString("echo hello", forType: .string)
#expect(view.menu(for: event)?.item(withTitle: pasteTitle)?.isEnabled == true)
view.pasteOnRightClick = true
#expect(view.menu(for: event) == nil)
let optionEvent = try #require(
NSEvent.mouseEvent(
with: .rightMouseDown, location: .zero, modifierFlags: .option, timestamp: 1,
windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
#expect(view.menu(for: optionEvent)?.item(withTitle: pasteTitle)?.isEnabled == true)
}
}
+4 -2
View File
@@ -208,7 +208,8 @@ struct TerminalMouseInteractionTests {
#expect(fixture.firstWrites.isEmpty)
fixture.setClipboard("do not paste")
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown, modifiers: .option))
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
// Inspect the Option menu without entering AppKit's modal tracking loop.
#expect(fixture.view.menu(for: try fixture.event(.rightMouseDown, modifiers: .option)) != nil)
try await fixture.flushWrites()
#expect(fixture.firstWrites.isEmpty)
fixture.feed("selection")
@@ -260,7 +261,8 @@ struct TerminalMouseInteractionTests {
fixture.setClipboard("paste once")
fixture.view.rightMouseDown(with: try fixture.event(.rightMouseDown))
fixture.view.pasteOnRightClick = true
fixture.view.rightMouseUp(with: try fixture.event(.rightMouseUp))
// The click already chose the menu route; avoid presenting it in this test.
#expect(fixture.view.menu(for: try fixture.event(.rightMouseDown)) != nil)
try await fixture.flushWrites()
#expect(fixture.firstWrites.isEmpty)
try fixture.rightClick()
+3 -3
View File
@@ -2940,10 +2940,10 @@
};
TRMD00000000000000100022 /* XCRemoteSwiftPackageReference "libghostty-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Lakr233/libghostty-spm";
repositoryURL = "https://github.com/AkinoKaede/libghostty-spm.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.6.20260922;
kind = revision;
revision = "21d9f81178fbb9a7d115a3a19cd9956fd5259f70";
};
};
TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */ = {
@@ -1,5 +1,5 @@
{
"originHash" : "b4d0d2a0d4dd48bc17305a7633056c1a5aeddd04bed354f9616d8b1b31e4e888",
"originHash" : "2386f4ec13a24a310cbe47f5923c524e937b76e0eb156a380e35eca9e70ec044",
"pins" : [
{
"identity" : "abseil-cpp-binary",
@@ -46,6 +46,15 @@
"version" : "5.9.0"
}
},
{
"identity" : "displaylink",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/DisplayLink.git",
"state" : {
"revision" : "41bea2661e04bebcb1c4f15fc0b6197850100fdd",
"version" : "3.0.1"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
@@ -157,10 +166,9 @@
{
"identity" : "libghostty-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/libghostty-spm",
"location" : "https://github.com/AkinoKaede/libghostty-spm.git",
"state" : {
"revision" : "b7f888e3baf8585ea9d590ab1a45b49475e00d1c",
"version" : "1.6.20260922"
"revision" : "21d9f81178fbb9a7d115a3a19cd9956fd5259f70"
}
},
{
+1
Submodule libghostty-spm added at 21d9f81178.