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
14 changes: 13 additions & 1 deletion Docs/LSP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ conversation with the server, and the join between the two.
language. Owns the handshake, the open-document set and the version counters.
- **`LSP/LanguageServerRegistry.swift`** — language → binary, one server per
(root, language), started lazily the first time a matching file is opened and
prewarmed from the dashboard.
prewarmed from the dashboard. It is also what stops one again: see
**Servers do not stay forever** below.
- **`LSP/LanguageServerConfiguration.swift`**, **`LanguageServerOptions.swift`**,
**`ManagedLanguageServers.swift`** — which servers exist, their flags, and the
ones the app can fetch for you.
Expand Down Expand Up @@ -137,6 +138,17 @@ been fine; they were not why.

## Also worth knowing

- **Servers do not stay forever.** A server holding no documents for fifteen
minutes is shut down and forgotten by the registry — the node ones are a few
hundred megabytes each, sourcekit-lsp rather more, and a dashboard prewarms up
to `prewarmLimit` of them before a single file has been opened. Nothing used
to stop one short of removing its repository from the sidebar, so a day across
eight repositories left every server of all eight resident. `LanguageService`
records `idleSince` when its open-document count reaches zero and tells the
registry; there is no sweep on a clock, because a server can only become idle
at the moment its last document closes. Opening a file in that language starts
a fresh one, which is what already happens the first time — `shutdown()`
clears the remembered handshake so it can.
- **Large files are excluded on purpose.** See `OpenDocument.largeFileNote`.
`didOpen` sends the whole text, and the answer to a 3.5 MB minified bundle is a
diagnostic per line of something nobody is editing. No coordinator is even
Expand Down
18 changes: 18 additions & 0 deletions Docs/Terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ sixteen ANSI slots a terminal needs.

## Gotchas learned the hard way

- **Ghostty's callbacks arrive at frame rate, and a hop each is too many.** A
shell painting a spinner — every `claude` mid-turn — asks to render every
frame whether or not anyone is looking at that tab, and wakes the app several
times per frame besides. One `Task { @MainActor … }` per request meant the
cost of a window grew with each terminal left running in it. Both are gathered
on ghostty's own thread instead (`RenderQueue`, `Latch` at the foot of
`GhosttyRuntime.swift`), and one hop serves everything that arrived while it
was waiting its turn. The latch is cleared at the *start* of the scheduled
work, so a request landing mid-drain schedules the next pass rather than being
swallowed by this one.
- **A tab title is not published on every rename, for the same reason.** `claude`
paints its spinner into the OSC title, and each name used to be a write to an
observable property — which redraws the terminals list, the tab bar and the
viewer header. `TerminalSession.titleArrived` splits what a name *means* from
the text of it: `isWorking` (what raises the "finished" banner) is read at
once and only written when it changes, while the text lands on a 200 ms tick.
The last name of a burst is always published, so a tab never settles showing
half a spinner.
- `ghostty_surface_text` is a **paste** (`completeClipboardPaste` in core).
Under bracketed paste, a pasted `\n` never executes a command — that is why
`TerminalSession.send` turns a trailing newline into `pressEnter()`, a
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ missing ones, so you never have to work out which command it wanted.
| --- | --- |
| A real terminal | The Ghostty engine, rooted at the repo. As many shells as you like. |
| Ask Claude (⇧⌘L) | The real Claude Code CLI in a floating panel — every flag, slash command and plugin works. Drag it, size it from any edge or corner, or fold it down to a bar on the dock along the bottom of the editor. One click on the bar brings it straight back, with no wait: a folded conversation keeps its terminal. |
| Park it (⇧⌘→) | While you are typing in a conversation, one key puts it back in the bottom-right corner of the editor, at the size you gave it. Anywhere else — the editor, a comment box — ⇧⌘→ still selects to the end of the line. |
| Many at once | As many conversations as you like, each named after what it is about, all on screen together. Fold the ones you are not reading down to the dock; when there are more bars than the editor is wide, the dock scrolls sideways, and dragging a bar moves it along the row. |
| Resume anything | Past conversations listed from disk — including ones you started in your own shell. |
| It tells you | A Notification Centre banner when a shell wants you back; click it to land on that tab. |
Expand Down Expand Up @@ -185,6 +186,7 @@ everything macOS binds) are not on offer there.
| ⌃⇥ | Switch repository |
| ⌘R | Refresh all repositories |
| ⇧⌘L | Ask Claude about the selected repo, in a panel over the window |
| ⇧⌘→ | Park the conversation you are typing in, bottom-right of the editor |
| ⌃⌘T / ⌘T / ⇧⌘T | Repo terminal · new tab · home terminal |
| ⌃` | Show the repo's terminal, and the same key to leave it |
| ⌃⌘P | Create PR with Claude Code |
Expand Down
67 changes: 64 additions & 3 deletions Sources/Workspace/LSP/LanguageServerRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ final class LanguageServerRegistry {
guard let entry = catalog.entry(forFile: url) ?? catalog.entry(for: language) else { return nil }
let key = Self.key(root: root, language: entry.language)
if let existing = services[key] { return existing }
let service = LanguageService(definition: Self.definition(for: entry), root: root)
let service = makeService(Self.definition(for: entry), root: root)
services[key] = service
return service
}
Expand Down Expand Up @@ -140,7 +140,7 @@ final class LanguageServerRegistry {

let key = Self.key(root: root, language: entry.language)
let service = services[key]
?? LanguageService(definition: Self.definition(for: entry), root: root)
?? makeService(Self.definition(for: entry), root: root)
services[key] = service
// Deliberately not awaited in turn: sourcekit-lsp can spend the
// best part of a minute on its handshake, and the servers behind it
Expand All @@ -163,7 +163,7 @@ final class LanguageServerRegistry {
}) else { return nil }
let key = Self.key(root: root, language: entry.language)
if let existing = services[key] { return existing }
let service = LanguageService(definition: Self.definition(for: entry), root: root)
let service = makeService(Self.definition(for: entry), root: root)
services[key] = service
return service
}
Expand Down Expand Up @@ -217,6 +217,67 @@ final class LanguageServerRegistry {
}
}

// MARK: - Letting idle servers go

/// How long a server may hold no documents before it is stopped.
///
/// Nothing used to stop one short of removing its repository from the
/// sidebar, and a server is not a cheap thing to leave sitting there: the
/// node ones are a few hundred megabytes each, sourcekit-lsp rather more,
/// and a repository prewarms up to ``prewarmLimit`` of them the first time
/// its dashboard is looked at. A working day across eight repositories left
/// every server of all eight resident, which is the machine slowing down
/// rather than the app — but it is the app that is blamed for it.
///
/// Long enough that reading two files in a language with a break between
/// them does not pay the handshake twice, and reading one file in a
/// repository visited by accident does not hold a server all afternoon.
private static let idleLimit: TimeInterval = 15 * 60

/// The one wait in flight. There is no sweep on a clock: a server can only
/// become idle by having its last document closed, so the wait is put on
/// when that happens and taken off when it is served.
@ObservationIgnored private var idleSweep: Task<Void, Never>?

/// Every service is made here, so every one of them reports going idle.
private func makeService(_ definition: LanguageServerDefinition, root: URL) -> LanguageService {
let service = LanguageService(definition: definition, root: root)
service.onIdle = { [weak self] in self?.scheduleIdleSweep() }
return service
}

private func scheduleIdleSweep(in delay: TimeInterval = LanguageServerRegistry.idleLimit) {
guard idleSweep == nil else { return }
idleSweep = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let self else { return }
shutdownIdleServices()
}
}

/// Stops everything that has been idle long enough, and puts the wait back
/// on for whatever is idle but not yet due.
private func shutdownIdleServices() {
idleSweep = nil
let now = Date()
var soonest: TimeInterval?

for (key, service) in services {
guard let idleSince = service.idleSince else { continue }
let idle = now.timeIntervalSince(idleSince)
if idle >= Self.idleLimit {
service.shutdown()
services[key] = nil
} else {
soonest = min(soonest ?? .greatestFiniteMagnitude, Self.idleLimit - idle)
}
}

// A server that went idle while this wait was running has its own left
// to serve; scheduling for the nearest keeps one wait for all of them.
if let soonest { scheduleIdleSweep(in: soonest) }
}

private static func key(root: URL, language: String) -> String {
"\(root.path)|\(language)"
}
Expand Down
38 changes: 37 additions & 1 deletion Sources/Workspace/LSP/LanguageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,32 @@ final class LanguageService {
/// coordinator to decide whether it may send ranges or must send the file.
private(set) var syncKind: LSP.SyncKind = .full

/// Since when this server has been holding no documents at all, or nil
/// while it is holding one.
///
/// A language server is an expensive thing to leave running — a few hundred
/// megabytes each for the node ones, more for sourcekit-lsp — and nothing
/// used to stop one until the repository it belongs to was removed from the
/// sidebar. A day of looking at eight repositories left every server of all
/// eight resident. ``LanguageServerRegistry`` reads this and stops the ones
/// that have been idle long enough; opening a file in that language starts
/// a fresh one, which is what already happens the first time.
private(set) var idleSince: Date? {
didSet { if idleSince != nil { onIdle?() } }
}

/// Told when this server stops holding documents, so the registry can put
/// a wait on it rather than sweeping every server on a clock.
@ObservationIgnored var onIdle: (() -> Void)?

@ObservationIgnored private let connection: LSPConnection
@ObservationIgnored private var versions: [String: Int] = [:]
@ObservationIgnored private var openCount: [String: Int] = [:]
@ObservationIgnored private var openCount: [String: Int] = [:] {
didSet {
guard openCount.isEmpty != oldValue.isEmpty else { return }
idleSince = openCount.isEmpty ? Date() : nil
}
}
@ObservationIgnored private var diagnosticObservers: [ObjectIdentifier: (String, [LSP.Diagnostic]) -> Void] = [:]
@ObservationIgnored private var startTask: Task<Bool, Never>?
@ObservationIgnored private var settings: LSP.Value?
Expand Down Expand Up @@ -121,6 +144,9 @@ final class LanguageService {
)
}
status = .running
// A prewarmed server holds nothing yet, so its idle clock
// starts here. `open` clears it the moment a file arrives.
if openCount.isEmpty, idleSince == nil { idleSince = Date() }
return true
} catch {
status = .failed(error.localizedDescription)
Expand All @@ -134,8 +160,18 @@ final class LanguageService {
}

func shutdown() {
// Before the resets below, so emptying the documents does not report a
// server that is going away as one that has just gone idle.
onIdle = nil
Task { await connection.stop() }
status = .notStarted
// The handshake is what `startIfNeeded` remembers; without this a
// service stopped for being idle could never be started again.
startTask = nil
openCount = [:]
versions = [:]
diagnostics = [:]
idleSince = nil
}

// MARK: - Document sync
Expand Down
23 changes: 21 additions & 2 deletions Sources/Workspace/Models/KeyboardShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable {
case openTerminal
case newTerminalTab
case newHomeTerminal
case parkChat

case submit

Expand All @@ -145,6 +146,7 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable {
case .openTerminal: "Open Terminal"
case .newTerminalTab: "New Terminal Tab"
case .newHomeTerminal: "New Terminal in Home"
case .parkChat: "Park Conversation"
case .submit: "Post / Commit"
}
}
Expand All @@ -169,6 +171,7 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable {
case .openTerminal: "Show the selected repository's terminal"
case .newTerminalTab: "Another shell beside the current one"
case .newHomeTerminal: "A shell that belongs to no repository"
case .parkChat: "Put the conversation you are typing in down in the bottom-right corner of the viewer"
case .submit: "Post the comment you typed, or commit the staged files"
}
}
Expand All @@ -183,7 +186,7 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable {
.editor
case .goToFile, .goBack, .goForward:
.go
case .askClaude, .toggleTerminal, .openTerminal, .newTerminalTab, .newHomeTerminal:
case .askClaude, .toggleTerminal, .openTerminal, .newTerminalTab, .newHomeTerminal, .parkChat:
.terminal
case .submit:
.writing
Expand Down Expand Up @@ -212,6 +215,16 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable {
case .openTerminal: KeyChord("t", [.control, .command])
case .newTerminalTab: KeyChord("t", [.command])
case .newHomeTerminal: KeyChord("t", [.command, .shift])
// ⇧ is on it because ⌘→ alone is already spoken for: it is "end of
// line" in every text view on the Mac, and on a keyboard where the
// arrows double as ⌥→/⌘→ the system takes it first — an app that binds
// it is one whose key sometimes fires and sometimes does not. ⇧⌘→ is
// "select to end of line", which belongs to a *text view* rather than to
// the system, and that is why it is reachable here: this is not a menu
// item, so the monitor only takes it while a conversation has the
// keyboard (see ``ChatPanelOverlay``) and the editor, a comment box and
// a commit message all keep the selection.
case .parkChat: KeyChord.rightArrow([.command, .shift])
case .submit: KeyChord.return([.command])
}
}
Expand Down Expand Up @@ -260,9 +273,15 @@ struct KeyChord: Hashable, Codable, Sendable {
/// The placeholder written to disk for an action bound to nothing.
static let unbound = KeyChord("", [])

/// The two keys the defaults use that type no character of their own.
/// The keys the defaults use that type no character of their own. An arrow
/// is one of AppKit's private-use codes rather than a character, which is
/// exactly what `charactersIgnoringModifiers` reports for it — so a recorded
/// arrow and this one are the same string.
static func tab(_ modifiers: KeyModifiers) -> KeyChord { KeyChord("\t", modifiers) }
static func `return`(_ modifiers: KeyModifiers) -> KeyChord { KeyChord("\r", modifiers) }
static func rightArrow(_ modifiers: KeyModifiers) -> KeyChord {
KeyChord(character(NSRightArrowFunctionKey), modifiers)
}

/// SwiftUI's side of it. Nil when the stored key is not a single character,
/// which only a hand-edited preference could produce.
Expand Down
13 changes: 13 additions & 0 deletions Sources/Workspace/Models/PullRequestBuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ struct PullRequestBuild: Identifiable, Sendable, Hashable {
}
}

/// Whether this is a verdict rather than a stage on the way to one.
/// A run that has settled will not change again, so there is nothing
/// left to learn by asking the host about it a second time — see
/// `PullRequestDetailView.watchBuilds`. `unknown` is deliberately not
/// settled: it is a word this app failed to read, not a host saying
/// the run is over.
var isSettled: Bool {
switch self {
case .passed, .failed, .cancelled, .skipped: true
case .running, .pending, .unknown: false
}
}

/// Every host spells these differently, and one host spells them
/// differently in two places — Cloud reports a pipeline's outcome in
/// `state.result.name` but a commit status's in `state`. Matching on
Expand Down
Loading
Loading