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
174 changes: 174 additions & 0 deletions .agents/sessions/2026-04-07-moriremote-ipad-support/plan.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions .agents/sessions/2026-04-07-moriremote-ipad-support/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Tasks: MoriRemote iPad Support

## Phase 1: Adaptive navigation and state foundation
- [x] 1.1 Extract reusable server list content from `MoriRemote/MoriRemote/Views/ServerListView.swift`
- [x] 1.2 Add a regular-width disconnected selection model and adaptive root view in `MoriRemote/MoriRemote/MoriRemoteApp.swift` / new `Views/*`
- [x] 1.3 Establish a stable terminal/session host boundary for layout changes
- [x] 1.4 Add regular-width detail content for empty/selected/connecting/failure states
- [x] 1.5 Preserve connection state across compact/regular transitions and keep selection resilient to split-view collapse

## Phase 2: Connected iPad workspace
- [x] 2.1 Refactor `MoriRemote/MoriRemote/Views/TerminalScreen.swift` for compact drawer vs regular persistent-sidebar presentation
- [x] 2.2 Adapt `TmuxSidebarView` and related sidebar components for regular-width persistent navigation
- [x] 2.3 Update dismiss-only sidebar/header assumptions for persistent iPad presentation
- [x] 2.4 Return cleanly to disconnected split view after disconnect / switch host / interrupted connect

## Phase 3: iPad polish, docs, and verification
- [x] 3.1 Adjust `MoriRemote/MoriRemote/Views/ServerFormView.swift` iPad sheet sizing/presentation
- [x] 3.2 Verify/fix add-edit invocation, save, dismiss, and list refresh in compact + regular flows
- [x] 3.3 Add/update localization for any new MoriRemote strings
- [x] 3.4 Update `CHANGELOG.md` and `README.md` if needed
- [x] 3.5 Build MoriRemote for iPad + iPhone simulators and fix issues
- [x] 3.6 Update any existing lightweight tests if applicable, otherwise document manual-only verification
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### ✨ Features

- **MoriRemote**: add adaptive iPad layouts with split-view server browsing while disconnected and a persistent two-pane workspace while connected
- **MoriRemote**: polish iPhone and iPad UI to follow the shared Mac-first `DESIGN.md` language with denser server rows, flatter tmux sidebars, compact terminal chrome, and normalized dark semantic styling
- **MoriRemote**: restyle the terminal accessory bar and key customization sheet with compact Mori tokens, semantic accent usage, and localized tmux actions
- **MoriRemote**: polish terminal connection microstates with richer iPad connection/failure detail states and a calmer in-terminal shell preparation overlay

### 🐛 Bug Fixes

- **MoriRemote**: make the regular-width terminal sidebar collapsible again and replace the crashing iPad keyboard-accessory tmux menu with a stable confirmation dialog
- **MoriRemote**: move compact terminal navigation into the accessory row by adding a back control beside tmux, keeping the terminal viewport free of extra chrome
- **MoriRemote**: harden terminal session lifecycle handling so disconnects, host switches, stale shell callbacks, and accessory-bar reuse no longer race into broken shell/tmux state
- **MoriRemote**: defer accessory-bar navigation and tmux/customization presentation until after the keyboard responder cycle, preventing crashes when tapping Back or tmux actions
- **MoriRemote**: preserve reconnect reliability after Back/disconnect by preventing stale disconnect tasks from overwriting a newer SSH connection attempt
- **MoriRemote**: stop the server list from hanging in "Connecting…" forever by surfacing missing-password and SSH timeout failures as explicit errors

### ♻️ Changes

- Remove legacy workflow-status and sidebar-mode code paths after the unified sidebar redesign, including the `mori status` CLI command and manual sidebar status controls
Expand Down
8 changes: 8 additions & 0 deletions MoriRemote/MoriRemote.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
57768C47189EF8BE03AD3106 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = CB7E8BE74D0419BD18CEA5E3 /* SwiftTerm */; };
57A0B148D1B8D23CA120481C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 495654252F6CE455BE0201B3 /* Assets.xcassets */; };
58C6389AD3DF6D861F49D1DE /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6666C37E7773D0C60B3D16BA /* ServerListView.swift */; };
58F100000000000000000001 /* RegularWidthServerBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F100000000000000000002 /* RegularWidthServerBrowserView.swift */; };
58F100000000000000000003 /* TerminalSessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F100000000000000000004 /* TerminalSessionHost.swift */; };
593DE6D8BD420A29CCDB02CD /* KeyAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F0FEA59D35705F0BCD383 /* KeyAction.swift */; };
5ACA156F8A500931E29BAD3E /* TerminalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EBAEEBBFE6A6FF22BFC722 /* TerminalScreen.swift */; };
5D0A706E5CC15CA815D2205C /* MoriSSH in Frameworks */ = {isa = PBXBuildFile; productRef = E4A46D15917AB2D06DABB5BF /* MoriSSH */; };
Expand Down Expand Up @@ -53,6 +55,8 @@
493C27506159F574E156F96C /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = "<group>"; };
495654252F6CE455BE0201B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4A727118EE1EEE5BDC793991 /* TmuxWindowRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxWindowRow.swift; sourceTree = "<group>"; };
58F100000000000000000002 /* RegularWidthServerBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularWidthServerBrowserView.swift; sourceTree = "<group>"; };
58F100000000000000000004 /* TerminalSessionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSessionHost.swift; sourceTree = "<group>"; };
6666C37E7773D0C60B3D16BA /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
6C511C1314958A8D89FC53C8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
879D7ABF80A185D8F5020407 /* TmuxBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxBarView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -156,10 +160,12 @@
isa = PBXGroup;
children = (
4E3BB9B3445110B4B4503A85 /* Sidebar */,
58F100000000000000000002 /* RegularWidthServerBrowserView.swift */,
93C9BF50025C03B0BCF8E77A /* ServerFormView.swift */,
6666C37E7773D0C60B3D16BA /* ServerListView.swift */,
348894A19970D2C348B4A692 /* SidebarContainer.swift */,
11EBAEEBBFE6A6FF22BFC722 /* TerminalScreen.swift */,
58F100000000000000000004 /* TerminalSessionHost.swift */,
A15134BC85B9E6519FF7F2A9 /* TmuxSidebarView.swift */,
);
path = Views;
Expand Down Expand Up @@ -274,13 +280,15 @@
376EEC1B30EE8565C4B085D1 /* MoriRemoteApp.swift in Sources */,
97B5FB0C7E80E60AF0D59B0E /* Server.swift in Sources */,
DE5E3473363E4A48BB9FAE83 /* ServerCardView.swift in Sources */,
58F100000000000000000001 /* RegularWidthServerBrowserView.swift in Sources */,
3E10DC5EF41F3B0DBD7E18E0 /* ServerFormView.swift in Sources */,
58C6389AD3DF6D861F49D1DE /* ServerListView.swift in Sources */,
949356F283B52C6F3AFC7B06 /* ServerStore.swift in Sources */,
6AB5F68B40467923E1395F97 /* ShellCoordinator.swift in Sources */,
F005EC1ECFC968DF06655D7E /* SidebarContainer.swift in Sources */,
F8BC0A57AF5F0A266F023AB0 /* TerminalAccessoryBar.swift in Sources */,
5ACA156F8A500931E29BAD3E /* TerminalScreen.swift in Sources */,
58F100000000000000000003 /* TerminalSessionHost.swift in Sources */,
96C8DDDEA3B929ED016AF50D /* TerminalView.swift in Sources */,
84BCE7CB7E779A71350C7FB7 /* Theme.swift in Sources */,
D2F9125D254E9980272FA1F7 /* TmuxBarView.swift in Sources */,
Expand Down
169 changes: 102 additions & 67 deletions MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,34 @@
import SwiftUI

/// Bottom sheet for customizing the keyboard accessory key bar.
/// Users toggle keys on/off. Changes apply live to the key bar.
struct KeyBarCustomizeView: View {
/// Direct reference to the UIKit key bar — mutations apply immediately.
let keyBar: KeyBarView
@Environment(\.dismiss) private var dismiss

/// Local copy of layout for SwiftUI reactivity.
@State private var layout: [KeyAction] = []

var body: some View {
NavigationStack {
List {
Section("Active Keys") {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "Customize Keys"))
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Theme.textPrimary)

Text(String(localized: "Pick the keys you want in the terminal accessory bar. Reorder active keys to keep your most-used actions close."))
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)
}
.listRowBackground(Color.clear)
}

Section(String(localized: "Active Keys")) {
let activeKeys = layout.filter { $0 != .divider }
if activeKeys.isEmpty {
Text("No keys added")
.foregroundStyle(.secondary)
.font(.subheadline)
Text(String(localized: "No keys added"))
.foregroundStyle(Theme.textSecondary)
.font(.system(size: 13))
} else {
ForEach(activeKeys, id: \.self) { action in
activeKeyRow(action)
Expand All @@ -38,28 +48,31 @@ struct KeyBarCustomizeView: View {
}

ForEach(KeyAction.Category.allCases, id: \.self) { category in
Section(category.rawValue) {
Section(category.localizedTitle) {
let actions = KeyAction.actions(for: category)
ForEach(actions, id: \.self) { action in
keyToggleRow(action)
}
}
}
}
.navigationTitle("Customize Keys")
.scrollContentBackground(.hidden)
.background(Theme.bg)
.navigationTitle(String(localized: "Customize Keys"))
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
ToolbarItem(placement: .topBarTrailing) {
Button("Reset") {
Button(String(localized: "Reset")) {
applyLayout(KeyAction.defaultLayout)
}
.foregroundStyle(Theme.destructive)
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
Button(String(localized: "Done")) { dismiss() }
.fontWeight(.semibold)
}
}
Expand All @@ -76,26 +89,20 @@ struct KeyBarCustomizeView: View {
KeyBarLayout.save(newLayout)
}

// MARK: - Active Key Row

private func activeKeyRow(_ action: KeyAction) -> some View {
HStack {
Text(action.label)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(keyColor(action))
.frame(width: 48, height: 28)
.background(keyBackground(action), in: RoundedRectangle(cornerRadius: 5))
HStack(spacing: 12) {
keyPreview(action, width: 52, height: 28)

Text(actionDescription(action))
.font(.subheadline)
.foregroundStyle(.secondary)
Text(action.localizedDescription)
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)

Spacer()
}
.padding(.vertical, 2)
.listRowBackground(Theme.mutedSurface)
}

// MARK: - Toggle Row

private func keyToggleRow(_ action: KeyAction) -> some View {
let isInBar = layout.contains(action)
return Button {
Expand All @@ -107,65 +114,93 @@ struct KeyBarCustomizeView: View {
}
applyLayout(newLayout)
} label: {
HStack {
Text(action.label)
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundStyle(keyColor(action))
.frame(width: 60, height: 30)
.background(keyBackground(action), in: RoundedRectangle(cornerRadius: 6))
HStack(spacing: 12) {
keyPreview(action, width: 62, height: 30)

Text(actionDescription(action))
.font(.subheadline)
.foregroundStyle(.secondary)
Text(action.localizedDescription)
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)

Spacer()

Image(systemName: isInBar ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isInBar ? Theme.accent : .secondary)
.foregroundStyle(isInBar ? Theme.accent : Theme.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.listRowBackground(Theme.mutedSurface)
}

// MARK: - Helpers
private func keyPreview(_ action: KeyAction, width: CGFloat, height: CGFloat) -> some View {
Text(action.label)
.font(.system(size: action.isSpecial ? 10 : 11, weight: .semibold, design: .monospaced))
.foregroundStyle(keyColor(action))
.frame(width: width, height: height)
.background(keyBackground(action), in: RoundedRectangle(cornerRadius: 7))
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(keyBorder(action), lineWidth: 1)
)
}

private func keyColor(_ action: KeyAction) -> Color {
if action.isTmux { return Color(Theme.accent) }
if action.isSpecial { return .white.opacity(0.55) }
return .white
if action.isTmux { return Theme.accent }
if action.isSpecial { return Theme.textSecondary }
return Theme.textPrimary
}

private func keyBackground(_ action: KeyAction) -> Color {
if action.isTmux { return Color(Theme.accent).opacity(0.1) }
if action.isSpecial { return Color(red: 0.118, green: 0.118, blue: 0.149) }
return Color(red: 0.165, green: 0.165, blue: 0.196)
if action.isTmux { return Theme.accentSoft }
if action.isSpecial { return Theme.elevatedBg }
return Theme.mutedSurface
}

private func actionDescription(_ action: KeyAction) -> String {
switch action {
case .esc: return "Escape key"
case .ctrl: return "Control modifier (sticky)"
case .alt: return "Alt/Meta modifier"
case .tab: return "Tab key"
case .tmuxPrefix: return "Tmux prefix (Ctrl+B)"
case .tmuxNewTab: return "New tab (⌘T)"
case .tmuxClosePane: return "Close pane (⌘W)"
case .tmuxNextTab: return "Next tab (⌘⇧])"
case .tmuxPrevTab: return "Previous tab (⌘⇧[)"
case .tmuxSplitH: return "Split right (⌘D)"
case .tmuxSplitV: return "Split down (⌘⇧D)"
case .tmuxNextPane: return "Next pane (⌘])"
case .tmuxPrevPane: return "Previous pane (⌘[)"
case .tmuxZoom: return "Toggle zoom (⌘⇧↩)"
case .tmuxDetach: return "Detach session"
case .left: return "Arrow left (auto-repeat)"
case .down: return "Arrow down (auto-repeat)"
case .up: return "Arrow up (auto-repeat)"
case .right: return "Arrow right (auto-repeat)"
case .home: return "Home key"
case .end: return "End key"
case .pageUp: return "Page up"
case .pageDown: return "Page down"
default: return "Send '\(action.label)'"
private func keyBorder(_ action: KeyAction) -> Color {
if action.isTmux { return Theme.accentBorder }
return Theme.cardBorder
}
}

private extension KeyAction.Category {
var localizedTitle: LocalizedStringKey {
switch self {
case .modifiers: return "Modifiers"
case .symbols: return "Symbols"
case .navigation: return "Navigation"
case .functionKeys: return "Function Keys"
case .tmux: return "Tmux Shortcuts"
}
}
}

private extension KeyAction {
var localizedDescription: String {
switch self {
case .esc: return String(localized: "Escape key")
case .ctrl: return String(localized: "Control modifier (sticky)")
case .alt: return String(localized: "Alt/Meta modifier")
case .tab: return String(localized: "Tab key")
case .tmuxPrefix: return String(localized: "Tmux prefix (Ctrl+B)")
case .tmuxNewTab: return String(localized: "New tab (⌘T)")
case .tmuxClosePane: return String(localized: "Close pane (⌘W)")
case .tmuxNextTab: return String(localized: "Next tab (⌘⇧])")
case .tmuxPrevTab: return String(localized: "Previous tab (⌘⇧[)")
case .tmuxSplitH: return String(localized: "Split right (⌘D)")
case .tmuxSplitV: return String(localized: "Split down (⌘⇧D)")
case .tmuxNextPane: return String(localized: "Next pane (⌘])")
case .tmuxPrevPane: return String(localized: "Previous pane (⌘[)")
case .tmuxZoom: return String(localized: "Toggle zoom (⌘⇧↩)")
case .tmuxDetach: return String(localized: "Detach session")
case .left: return String(localized: "Arrow left (auto-repeat)")
case .down: return String(localized: "Arrow down (auto-repeat)")
case .up: return String(localized: "Arrow up (auto-repeat)")
case .right: return String(localized: "Arrow right (auto-repeat)")
case .home: return String(localized: "Home key")
case .end: return String(localized: "End key")
case .pageUp: return String(localized: "Page up")
case .pageDown: return String(localized: "Page down")
default: return String(localized: "Send key") + " ‘\(label)’"
}
}
}
Expand Down
Loading
Loading