Fix command modified keys. Add plan for splitscreen

This commit is contained in:
2026-03-13 20:25:18 +11:00
parent 1e30e9bf9e
commit 8ecb7d4382
9 changed files with 1044 additions and 482 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -15,7 +15,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
@@ -28,7 +28,42 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -44,12 +79,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -61,12 +98,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -15,7 +15,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
@@ -28,13 +28,48 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "1"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
@@ -44,12 +79,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -61,12 +98,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -0,0 +1,31 @@
import AppKit
import Carbon.HIToolbox
import SwiftTerm
enum TerminalCommandArrowBehavior {
private static let relevantModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
private static let lineKill: [UInt8] = [0x15]
private static let clearScreen: [UInt8] = [0x0c]
static func sequence(
for modifierFlags: NSEvent.ModifierFlags,
keyCode: UInt16,
applicationCursor: Bool
) -> [UInt8]? {
let flags = modifierFlags.intersection(relevantModifiers)
guard flags == [.command] else { return nil }
switch Int(keyCode) {
case kVK_LeftArrow:
return applicationCursor ? EscapeSequences.moveHomeApp : EscapeSequences.moveHomeNormal
case kVK_RightArrow:
return applicationCursor ? EscapeSequences.moveEndApp : EscapeSequences.moveEndNormal
case kVK_Delete:
return lineKill
case kVK_ANSI_L:
return clearScreen
default:
return nil
}
}
}

View File

@@ -9,6 +9,7 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
let id = UUID()
let terminalView: TerminalView
private var process: LocalProcess?
private var keyEventMonitor: Any?
private let backgroundColor = NSColor.black
private let configuredShellPath: String
@@ -26,10 +27,17 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font
applyTheme(theme)
installCommandArrowMonitor()
startShell()
}
deinit {
if let keyEventMonitor {
NSEvent.removeMonitor(keyEventMonitor)
}
}
// MARK: - Shell management
private func startShell() {
@@ -58,6 +66,26 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
}
private func installCommandArrowMonitor() {
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
guard let window = self.terminalView.window else { return event }
guard event.window === window else { return event }
guard window.firstResponder === self.terminalView else { return event }
guard let sequence = TerminalCommandArrowBehavior.sequence(
for: event.modifierFlags,
keyCode: event.keyCode,
applicationCursor: self.terminalView.getTerminal().applicationCursor
) else {
return event
}
self.terminalView.send(data: sequence[...])
return nil
}
}
func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}

View File

@@ -0,0 +1,57 @@
import AppKit
import Carbon.HIToolbox
import XCTest
import SwiftTerm
@testable import CommandNotch
final class TerminalCommandArrowBehaviorTests: XCTestCase {
func testCommandLeftUsesHomeSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertEqual(sequence, EscapeSequences.moveHomeNormal)
}
func testCommandRightUsesApplicationEndSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_RightArrow),
applicationCursor: true
)
XCTAssertEqual(sequence, EscapeSequences.moveEndApp)
}
func testOptionLeftKeepsSwiftTermWordNavigationPath() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.option],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertNil(sequence)
}
func testCommandDeleteUsesLineKillSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_Delete),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x15])
}
func testCommandLUsesClearScreenSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_ANSI_L),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x0c])
}
}

View File

@@ -14,6 +14,45 @@ packages:
SwiftTerm:
url: https://github.com/migueldeicaza/SwiftTerm.git
from: "1.2.0"
schemes:
CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Debug
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
Release-CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Release
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
targets:
CommandNotch:
type: application
@@ -29,9 +68,9 @@ targets:
properties:
CFBundleName: CommandNotch
CFBundleDisplayName: CommandNotch
CFBundleIdentifier: com.commandnotch.app
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
CFBundleVersion: "1"
CFBundleShortVersionString: "0.2.0"
CFBundleShortVersionString: "0.0.3"
CFBundlePackageType: APPL
CFBundleExecutable: CommandNotch
LSMinimumSystemVersion: "14.0"

View File

@@ -0,0 +1,351 @@
# Workspace Split Pane Plan
## Purpose
Capture the agreed design for workspace-owned split terminals so the feature can be implemented later without redoing scope and architecture decisions.
## Agreed Scope
- Split layouts belong to the workspace, not the screen.
- A workspace may only be actively presented on one screen at a time.
- Split layouts should be visible the same way when that workspace is opened on another monitor.
- Nested splits are supported.
- Any two tabs may be joined into a split.
- Closing a pane causes its sibling to expand.
- Split layout must survive relaunch.
## Non-Goals
- Simultaneous live rendering of the same workspace on multiple screens.
- Full multi-user or collaborative state sync.
- Arbitrary freeform pane layout. The model is tree-based binary splits.
- Solving all interaction polish in the first pass.
## Current Constraint
The current workspace model is flat:
- `WorkspaceController` owns `[TerminalSession]`
- one tab is active at a time
- `ContentView` renders exactly one active `SwiftTermView`
This means split panes are not an additive UI feature. They require a workspace model refactor.
## Recommended Model
Do not model splits as "adjacent tabs with special behavior" internally.
Instead:
1. A workspace owns ordered top-level tabs.
2. Each top-level tab owns a pane tree.
3. Pane tree leaves own `TerminalSession` instances.
4. Pane tree internal nodes represent binary splits.
5. The tab bar can visually present grouped/joined tabs based on the active tab's pane composition.
This keeps the domain model clean while still allowing the desired visual metaphor.
## Proposed Domain Types
```swift
typealias WorkspaceTabID = UUID
typealias PaneID = UUID
struct WorkspaceTab: Identifiable, Equatable, Codable {
var id: WorkspaceTabID
var rootPane: PaneNode
var selectedPaneID: PaneID?
var titleMode: WorkspaceTabTitleMode
}
enum PaneNode: Equatable, Codable, Identifiable {
case leaf(PaneLeaf)
case split(PaneSplit)
var id: PaneID { ... }
}
struct PaneLeaf: Equatable, Codable, Identifiable {
var id: PaneID
var sessionID: UUID
}
struct PaneSplit: Equatable, Codable, Identifiable {
var id: PaneID
var axis: SplitAxis
var ratio: Double
var first: PaneNode
var second: PaneNode
}
enum SplitAxis: String, Codable {
case horizontal
case vertical
}
```
Notes:
- `ratio` should be persisted so user-adjusted divider positions survive relaunch.
- `selectedPaneID` allows focus-based commands such as split active pane, close active pane, and move focus.
- The persisted model should store session identity separately from runtime AppKit/SwiftTerm objects.
## Runtime Ownership
Global:
- workspace summaries
- workspace ordering
- workspace-to-screen assignment
- hotkeys
Workspace:
- ordered top-level tabs
- active top-level tab
- per-tab pane tree
- active/selected pane
- terminal sessions for pane leaves
Screen:
- which workspace is assigned
- notch open/close state
- geometry and transitions
This remains aligned with the existing workspace architecture.
## Persistence Strategy
Split layout must survive relaunch, so workspace persistence needs to grow beyond summary metadata.
Recommended approach:
1. Keep `WorkspaceSummary` for list metadata only.
2. Introduce a persisted `WorkspaceDocument` or `WorkspaceSnapshot` per workspace.
3. Persist:
- top-level tabs
- pane tree
- active tab ID
- selected pane ID
- divider ratios
4. Do not attempt to persist shell process contents.
5. On relaunch, recreate sessions for pane leaves and restore layout structure only.
Important distinction:
- layout persistence is required
- terminal process continuity is not
## UI Model
### Main Content
The active top-level tab renders as a recursive split tree.
- leaf node -> one `SwiftTermView`
- split node -> `HSplitView`/`VSplitView` equivalent SwiftUI container with draggable divider
### Tab Bar
Top-level tabs remain the primary navigation unit.
Visual behavior:
- a normal unsplit tab looks like today
- a joined/split tab should look grouped
- grouped tabs should expose child pane titles visually inside the tab item
Recommended first-pass appearance:
- one outer tab pill per top-level tab
- inside that pill, show compact child title chips for each leaf pane
- highlight the selected pane's chip
This gives the "tabs are joined" feel without making pane leaves first-class top-level tabs in the data model.
## Command Model
New actions likely needed:
- split active pane horizontally
- split active pane vertically
- focus next pane
- focus previous pane
- close active pane
- resize focused split divider
- join tab A into tab B
- detach pane into new top-level tab
Semantics:
- joining any two tabs merges one tab's root pane into the other's tree
- the source top-level tab is removed
- the destination top-level tab remains
- sibling expansion on close is standard tree collapse
## Join Semantics
Joining any two tabs should work as:
1. Choose destination tab.
2. Choose source tab.
3. Replace destination root with a new split node:
- first child = old destination root
- second child = source root
4. Remove source tab from workspace order.
5. Select a predictable pane, preferably the source pane that was just added.
An explicit split axis should be required for the join action.
## Close Semantics
When closing a pane:
- if the pane has a sibling, the sibling expands into the parent's position
- if the pane was the only leaf in a top-level tab:
- close the whole top-level tab if more than one tab exists
- otherwise create a replacement shell pane, matching current single-tab safety behavior
## Focus Semantics
Pane focus must become explicit.
Recommended rules:
- mouse click focuses that pane
- splitting focuses the new pane
- joining focuses the moved-in pane
- closing a pane focuses the surviving sibling
- top-level tab switch restores the previously selected pane in that tab
## Migration Path
Implement in stages to reduce risk.
### Stage 1: Domain Refactor
- Replace flat workspace tabs with top-level tab objects.
- Introduce pane tree types.
- Keep only one leaf per tab initially so behavior is unchanged.
### Stage 2: Runtime Layout Rendering
- Render pane trees recursively in `ContentView`.
- Add active pane selection.
- Keep persistence off until runtime behavior is stable.
### Stage 3: Split Actions
- Split active pane horizontally/vertically.
- Close pane with sibling expansion.
- Basic focus movement.
### Stage 4: Joined Tab UI
- Update tab bar to show grouped child pane chips.
- Surface active pane clearly.
### Stage 5: Join / Unjoin Flows
- Join any two tabs with explicit axis choice.
- Support promoting a pane back to its own top-level tab if needed.
### Stage 6: Persistence
- Persist pane trees and top-level tab state.
- Recreate sessions on launch.
### Stage 7: Hotkeys and Polish
- Add pane-focused shortcuts.
- Add divider dragging polish.
- Improve visual grouped-tab affordances.
## Main Risks
### 1. Model complexity
The current flat `[TerminalSession]` model is simple. Tree-based layout introduces more state, more edge cases, and more focus semantics.
Mitigation:
- refactor the data model before touching complex UI
- keep top-level tabs and pane leaves distinct
### 2. SwiftTerm view ownership
`TerminalView` cannot be mounted in multiple places safely.
Mitigation:
- preserve current rule: only one active presenting screen per workspace
- keep one runtime `TerminalSession` per pane leaf
### 3. Persistence mismatch
Persisting layout is easy compared with persisting process state.
Mitigation:
- persist layout and selection only
- recreate shell sessions on launch
### 4. Joined-tab UX ambiguity
If grouped tabs are also used as pane chips, the interaction model can get confusing.
Mitigation:
- preserve top-level tabs as the real navigation unit
- use internal chips only as secondary indicators/actions
## Recommended First Implementation Boundary
The first deliverable should include:
- nested binary split trees
- active pane focus
- split and close pane actions
- sibling expansion
- grouped tab appearance
- join any two tabs
- persisted layout across relaunch
- same workspace layout when moved to another screen
The first deliverable should not include:
- simultaneous same-workspace rendering on multiple screens
- drag-and-drop tree editing
- restoring running shell process contents
## Files Likely Impacted
- `Downterm/CommandNotch/Models/WorkspaceController.swift`
- `Downterm/CommandNotch/Models/WorkspaceRegistry.swift`
- `Downterm/CommandNotch/Models/WorkspaceStore.swift`
- `Downterm/CommandNotch/ContentView.swift`
- `Downterm/CommandNotch/Components/TabBar.swift`
- `Downterm/CommandNotch/Models/TerminalSession.swift`
- workspace-related tests
Likely new files:
- pane tree domain types
- persisted workspace document types
- split-pane rendering view(s)
- pane-focused command helpers
## Decision Summary
The feature is feasible.
The correct architecture is:
- workspace-owned split tree
- top-level tabs remain the primary unit
- grouped tab visuals are a UI layer over the pane tree
- one presenting screen per workspace
- persisted layout, not persisted process contents
That is the implementation direction to use when this work is resumed.