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
7 changes: 7 additions & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ to Developer ID signing and notarization once its signing secrets are set
(see [macOS release signing](macos-release-signing.md)); this workaround is
removed from here with the first notarized release.

Long chats can be read from the keyboard: Page Up and Page Down (fn-↑ and
fn-↓ on a laptop) scroll the chat by a screen, Home goes to its first message
and End to the latest, following new output again like the Latest button. The
keys work while the composer or chat search has focus, since they do not edit
text there; with Shift, Option, Control or Command they are left to the text
field, and they stay with the terminal, the preview and open sheets.

Questions the agent asks (`ask_question`) appear as cards: one button per
option, ticks and Submit for multi-select questions, and a free-text answer.

Expand Down
28 changes: 26 additions & 2 deletions macos/Sources/RustCode/ChatScroller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ final class ChatScrollState: ObservableObject {
}

/// A page: the visible height, less a little kept for context.
var page: Double { max(visible - 40, 40) }
var page: Double { TranscriptKeys.pageDistance(visible: visible) }

private var updatePending = false

Expand Down Expand Up @@ -267,7 +267,31 @@ struct EnclosingScrollView: NSViewRepresentable {

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let scrollView = enclosingScrollView { found(scrollView) }
report()
}

/// SwiftUI can add the probe to a window before it puts it inside the
/// scroll view, so the scroller had none to move: look again once
/// the probe is laid out, and after the pass that placed it.
override func layout() {
super.layout()
report()
}

private weak var reported: NSScrollView?

private func report() {
if let scrollView = enclosingScrollView {
guard scrollView !== reported else { return }
reported = scrollView
found(scrollView)
} else if window != nil {
DispatchQueue.main.async { [weak self] in
guard let self, let scrollView = self.enclosingScrollView, scrollView !== self.reported else { return }
self.reported = scrollView
self.found(scrollView)
}
}
}

override func hitTest(_ point: NSPoint) -> NSView? { nil }
Expand Down
72 changes: 72 additions & 0 deletions macos/Sources/RustCode/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import AppKit
import RustCodeKit
import class SwiftTerm.TerminalView
import SwiftUI
import WebKit

/// The window has no title bar: a gray sidebar runs to the top edge beside the
/// chat, with no split-view divider between them.
Expand Down Expand Up @@ -641,6 +643,9 @@ private struct ChatView: View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
// Home's target: always the stack's first child, so the
// jump finds it however far down the chat is.
Color.clear.frame(height: 0).id(topAnchor)
ForEach(transcript.items[..<split]) { item in
TranscriptRow(item: item, isCurrentMatch: item.id == currentMatch)
.equatable()
Expand Down Expand Up @@ -718,6 +723,11 @@ private struct ChatView: View {
}
}
.followsLatest($follow, scroll: scroll) { scrollToEnd(proxy) }
.onAppear {
TranscriptKeyInput.shared.start()
TranscriptKeyInput.shared.isCovered = { [model] in model.coversChat }
TranscriptKeyInput.shared.perform = { action in perform(action, proxy) }
}
.onGeometryChange(for: CGRect.self) { proxy in
// Only the part of the chat between the title bar and the
// composer: scrolling the composer or a card above it is not
Expand Down Expand Up @@ -869,6 +879,7 @@ private struct ChatView: View {
}

private let bottomAnchor = "bottom"
private let topAnchor = "top"
/// The working indicator's place in the scroller's map, after every row.
private static let workingRow = Int.max
/// Newest rows laid out eagerly; everything older is lazy.
Expand All @@ -893,6 +904,28 @@ private struct ChatView: View {
scrollToEnd(proxy)
}

/// Page Up, Page Down, Home or End over the chat (`TranscriptKeys`).
/// False when there is nothing to scroll, so the key goes on as usual.
private func perform(_ action: TranscriptKeys.Action, _ proxy: ScrollViewProxy) -> Bool {
guard !chat.transcript.isEmpty, !model.isLoadingSelected else { return false }
let visible = scroll.visible
let page = TranscriptKeys.pageDistance(visible: visible)
switch TranscriptKeys.resolved(action, distanceFromBottom: follow.geometry?.distanceFromBottom, visible: visible) {
case .pageUp:
follow.letGo()
scroll.scroll(by: -page, animated: true)
case .pageDown:
scroll.scroll(by: page, animated: true)
case .top:
follow.letGo()
proxy.scrollTo(topAnchor, anchor: .top)
case .end:
jumpToLatest(proxy)
}
scroll.flash()
return true
}

/// The shown chat has a turn in progress, so its last reply may still grow.
private var isReplyStreaming: Bool {
model.isTurnRunning
Expand Down Expand Up @@ -1070,6 +1103,45 @@ private final class ChatScrollInput {
}
}

/// Page Up, Page Down, Home and End for the chat, caught before the focused
/// view sees them. Which keys and when is `TranscriptKeys`; this only tells
/// it where the focus is.
@MainActor
private final class TranscriptKeyInput {
static let shared = TranscriptKeyInput()
/// A sheet or panel is over the chat.
var isCovered: () -> Bool = { false }
/// Runs an action; false when the chat had nothing to scroll.
var perform: (TranscriptKeys.Action) -> Bool = { _ in false }
private var monitor: Any?

func start() {
guard monitor == nil else { return }
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
MainActor.assumeIsolated { TranscriptKeyInput.shared.handle(event) } ? nil : event
}
}

private func handle(_ event: NSEvent) -> Bool {
let modified = !event.modifierFlags.intersection([.shift, .option, .control, .command]).isEmpty
guard let window = event.window, window.attachedSheet == nil,
let action = TranscriptKeys.action(keyCode: event.keyCode, modified: modified,
focus: focus(window.firstResponder)) else { return false }
return perform(action)
}

private func focus(_ responder: NSResponder?) -> TranscriptKeys.Focus {
if isCovered() { return .panel }
var view = responder as? NSView
while let current = view {
if current is TerminalView { return .terminal }
if current is WKWebView { return .web }
view = current.superview
}
return responder is NSText || responder is NSTextField ? .textField : .chat
}
}

/// ⌘F: find in the chat. ⏎ and ⌘G go to the next match, ⇧⏎ and ⇧⌘G back, Esc closes.
private struct FindBar: View {
@ObservedObject var model: DesktopModel
Expand Down
16 changes: 16 additions & 0 deletions macos/Sources/RustCode/DebugDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ enum DebugDriver {
let parts = line.split(separator: " ").map(String.init)
switch parts.first {
case "select": if let s = session(parts[1], model) { await model.select(s) }
case "open":
if let s = model.sessions.first(where: { $0.id == parts[1] }) { await model.select(s) }
case "pin": if let s = session(parts[1], model) { model.togglePin(s) }
case "new": await model.newSession()
case "dump":
Expand All @@ -47,6 +49,20 @@ enum DebugDriver {
}
if let content = NSApp.windows.first?.contentView { walk(content) }
try? lines.joined(separator: "\n").write(toFile: (ProcessInfo.processInfo.environment["RUSTCODE_DRIVE"] ?? "/tmp") + "/dump", atomically: true, encoding: .utf8)
case "key":
// A key press as the keyboard sends it: `key <code> [shift|cmd|opt]`.
guard let window = NSApp.windows.first(where: { $0.isVisible && $0.contentView != nil }),
let code = UInt16(parts[1]) else { break }
var flags: NSEvent.ModifierFlags = [.function]
if parts.contains("shift") { flags.insert(.shift) }
if parts.contains("cmd") { flags.insert(.command) }
if parts.contains("opt") { flags.insert(.option) }
let characters = parts.count > 2 && !["shift", "cmd", "opt"].contains(parts[2]) ? parts[2] : ""
if let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil, characters: characters,
charactersIgnoringModifiers: characters, isARepeat: false, keyCode: code) {
NSApp.postEvent(event, atStart: false)
}
case "size":
NSApp.windows.first?.setFrame(NSRect(x: 80, y: 80, width: Double(parts[1])!, height: Double(parts[2])!), display: true)
default: break
Expand Down
5 changes: 5 additions & 0 deletions macos/Sources/RustCode/RustCodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,11 @@ final class DesktopModel: ObservableObject {
}
}

/// A sheet or panel is open over the chat, which keeps its keys.
var coversChat: Bool {
showsSettings || renamingSession != nil || contextSheet != nil || commandResult != nil || composerPanel != nil
}

/// The request shown for the selected session, if any.
var pendingPermission: PermissionRequest? {
permissionQueue.first { $0.sessionId == selectedSession }
Expand Down
73 changes: 73 additions & 0 deletions macos/Sources/RustCodeKit/TranscriptKeys.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation

/// Reading a long chat from the keyboard (#2191).
///
/// - Page Up / Page Down (fn-↑ / fn-↓ on a laptop) scroll the chat by its
/// visible height, less a little kept for context.
/// - Home / End (fn-← / fn-→) go to the first message and to the latest one;
/// End follows the chat's output again, as the Latest button does.
///
/// The keys reach the chat while the composer or a search field has focus:
/// none of them edit text there (a text field's Home and End only scroll its
/// own document, and the composer never grows tall enough to page). With
/// Shift, Option, Control or Command they are left alone, so selecting by page
/// still works in the composer. They never reach the chat from the terminal,
/// the preview or an open sheet, which have their own uses for them.
public enum TranscriptKeys {
public enum Action: Equatable, Sendable {
case pageUp, pageDown, top, end
}

/// Where the keyboard focus is when a key comes in.
public enum Focus: Equatable, Sendable {
/// The chat itself, a button or nothing in particular.
case chat
/// The composer or a one-line field such as chat search.
case textField
/// The workspace terminal, which pages its own scrollback.
case terminal
/// The localhost preview's page.
case web
/// A sheet or panel over the chat, such as Settings.
case panel
}

/// macOS virtual key codes.
public enum KeyCode {
public static let home: UInt16 = 115
public static let pageUp: UInt16 = 116
public static let end: UInt16 = 119
public static let pageDown: UInt16 = 121
}

/// The chat action for a key press, or nil to leave the key to whoever
/// has focus. `modified` is whether Shift, Option, Control or Command is
/// held; the Function key alone does not count, as laptops need it to
/// type these keys at all.
public static func action(keyCode: UInt16, modified: Bool, focus: Focus) -> Action? {
guard !modified, focus == .chat || focus == .textField else { return nil }
switch keyCode {
case KeyCode.pageUp: return .pageUp
case KeyCode.pageDown: return .pageDown
case KeyCode.home: return .top
case KeyCode.end: return .end
default: return nil
}
}

/// Kept from one page to the next, so a line read last is still in view.
public static let pageOverlap: Double = 40

/// How far one page scrolls in a chat this tall between its insets.
public static func pageDistance(visible: Double) -> Double {
max(visible - pageOverlap, pageOverlap)
}

/// A page down that would reach the end goes all the way there and holds
/// on, so output streaming in afterwards stays in view. `distanceFromBottom`
/// is how much further the chat can scroll, when known.
public static func resolved(_ action: Action, distanceFromBottom: Double?, visible: Double) -> Action {
guard action == .pageDown, let distanceFromBottom else { return action }
return distanceFromBottom <= pageDistance(visible: visible) + ChatFollow.threshold ? .end : action
}
}
66 changes: 66 additions & 0 deletions macos/Tests/RustCodeKitTests/TranscriptKeysTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import XCTest
@testable import RustCodeKit

final class TranscriptKeysTests: XCTestCase {
private typealias Key = TranscriptKeys.KeyCode

func testPageAndJumpKeysMapToChatActions() {
XCTAssertEqual(TranscriptKeys.action(keyCode: Key.pageUp, modified: false, focus: .chat), .pageUp)
XCTAssertEqual(TranscriptKeys.action(keyCode: Key.pageDown, modified: false, focus: .chat), .pageDown)
XCTAssertEqual(TranscriptKeys.action(keyCode: Key.home, modified: false, focus: .chat), .top)
XCTAssertEqual(TranscriptKeys.action(keyCode: Key.end, modified: false, focus: .chat), .end)
}

/// The composer keeps focus while reading: the keys still scroll the chat.
func testKeysReachTheChatFromTheComposer() {
XCTAssertEqual(TranscriptKeys.action(keyCode: Key.pageDown, modified: false, focus: .textField), .pageDown)
XCTAssertEqual(TranscriptKeys.action(keyCode: Key.end, modified: false, focus: .textField), .end)
}

func testOtherKeysAreLeftAlone() {
// Arrows, Return and letters belong to whoever has focus.
for code: UInt16 in [123, 124, 125, 126, 36, 0, 49] {
XCTAssertNil(TranscriptKeys.action(keyCode: code, modified: false, focus: .chat), "key \(code)")
}
}

/// Shift-Page Down selects by page in the composer; ⌘ and ⌥ variants are
/// the system's.
func testModifiedKeysAreLeftAlone() {
for code in [Key.pageUp, Key.pageDown, Key.home, Key.end] {
XCTAssertNil(TranscriptKeys.action(keyCode: code, modified: true, focus: .textField))
XCTAssertNil(TranscriptKeys.action(keyCode: code, modified: true, focus: .chat))
}
}

/// The terminal pages its scrollback, the preview its page, and a sheet
/// over the chat its own content.
func testKeysStayWithTerminalPreviewAndPanels() {
for focus: TranscriptKeys.Focus in [.terminal, .web, .panel] {
for code in [Key.pageUp, Key.pageDown, Key.home, Key.end] {
XCTAssertNil(TranscriptKeys.action(keyCode: code, modified: false, focus: focus), "\(focus) \(code)")
}
}
}

func testAPageIsTheVisibleHeightLessTheOverlap() {
XCTAssertEqual(TranscriptKeys.pageDistance(visible: 600), 560)
// A tiny chat still moves.
XCTAssertEqual(TranscriptKeys.pageDistance(visible: 50), TranscriptKeys.pageOverlap)
XCTAssertEqual(TranscriptKeys.pageDistance(visible: 0), TranscriptKeys.pageOverlap)
}

/// The last page down lands on the end and follows output from there.
func testPageDownNearTheEndGoesToTheEnd() {
XCTAssertEqual(TranscriptKeys.resolved(.pageDown, distanceFromBottom: 300, visible: 600), .end)
XCTAssertEqual(TranscriptKeys.resolved(.pageDown, distanceFromBottom: 560 + ChatFollow.threshold, visible: 600), .end)
XCTAssertEqual(TranscriptKeys.resolved(.pageDown, distanceFromBottom: 2_000, visible: 600), .pageDown)
XCTAssertEqual(TranscriptKeys.resolved(.pageDown, distanceFromBottom: nil, visible: 600), .pageDown)
}

func testOnlyPageDownIsResolved() {
for action: TranscriptKeys.Action in [.pageUp, .top, .end] {
XCTAssertEqual(TranscriptKeys.resolved(action, distanceFromBottom: 0, visible: 600), action)
}
}
}
Loading