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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Nirux is alpha software.
## Highlights

- Persistent workspaces: keep each task's name and context visible, stack workspaces vertically, and archive inactive ones in a collapsible sidebar section that does not poll GitHub. Collapsed, the sidebar is a rail: the project on top (its menu switches projects), then a tile per workspace with its initials (a branch name drops its type, so `feat/crash-report` reads CR) and its state in the corner, amber only when an agent waits for you; inactive workspaces stay behind an archive tile, and "+" opens a new one. Hover a tile for what it's doing; click, drag and right-click work as on the cards.
- Horizontal columns: mix Ghostty-backed terminals, WKWebView browser columns, and Monaco editor columns in the same workspace. `Cmd+F` in a terminal searches its scrollback with Ghostty's native search, highlighting every match.
- Horizontal columns: mix Ghostty-backed terminals, WKWebView browser columns, and Monaco editor columns in the same workspace. `Cmd+F` in a terminal searches its scrollback with Ghostty's native search, highlighting every match; in a browser column, WebKit's find searches the page and counts the matches.
- Agent launchers: start Claude Code or Codex from the command palette with configurable permission and sandbox presets.
- Attention and Activity: per-column agent status (working / needs attention, with elapsed time) driven by real Claude Code hooks and Codex turn notifications — not output guessing (Gemini CLI and OpenCode, which have no hooks, get output-activity status) — plus a persistent sidebar feed, edge glows for off-screen attention, native macOS notifications that focus the right workspace and column on click, and a Dock badge counting waiting workspaces.
- Stuck agents: a permission or question left open past a threshold (Settings, 10 minutes by default) shows `waiting 2h05m` on its card and notifies once (Telegram too, and while Nirux is in the background); a Claude turn that ended on an API error shows `API error`, with a Resume button — for transient errors only (overloaded, server error) — that types `continue` only on a click, once Claude is back at an empty prompt; a `claude` that died mid-turn gets an overlay that resumes its conversation in its permission mode.
Expand Down Expand Up @@ -145,8 +145,8 @@ Useful shortcuts:
| `Cmd+Z` / `Shift+Cmd+Z` | Undo / redo in the editor and in panel text fields (palette, rename, settings) |
| `Shift+Cmd+F` | Search workspace |
| `Alt+Cmd+F` | Search Everywhere: text in the scrollback of every terminal, across workspaces and projects; picking a match opens its terminal's find bar on it. A full-screen program (vim, less, Claude Code in its no-flicker mode) shows no scrollback: only its screen is searched. Then the transcripts of the Claude sessions the history knows (running or past, only what you typed and Claude answered, read in place within a time budget); picking one of their matches resumes the session, or goes to its column |
| `Cmd+F` | Find in the focused editor or terminal; a terminal's find bar closes with `Esc` |
| `Cmd+G` / `Shift+Cmd+G` | Next / previous terminal match (`Return` / `Shift+Return` in the find bar); next moves up to older output, as in Ghostty |
| `Cmd+F` | Find in the focused editor, terminal or browser page (unless the page has its own search); the find bar closes with `Esc` (on a page, from its field) |
| `Cmd+G` / `Shift+Cmd+G` | Next / previous match (`Return` / `Shift+Return` in the find bar); in a terminal, next moves up to older output, as in Ghostty |
| `Cmd+S` / `Alt+Cmd+S` | Save the active editor file / save all |
| `Shift+Cmd+D` | Toggle editor diff |
| `Alt+Cmd+Z` | Toggle word wrap in editor |
Expand Down
7 changes: 4 additions & 3 deletions Sources/Nirux/Input/TerminalFindKeyRouting.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import AppKit

/// Where the key interceptor sends a key when a terminal's find bar is
/// involved. Terminal columns otherwise write every key to the PTY.
/// Where the key interceptor sends a key when a find bar is involved: a
/// terminal's, or a browser column's. Terminal columns otherwise write every
/// key to the PTY.
enum TerminalFindKeyRouting {
enum FieldRoute: Equatable {
/// Plain keys (Return, Escape, arrows included) edit the field.
Expand All @@ -16,7 +17,7 @@ enum TerminalFindKeyRouting {
case fieldEditor
}

/// A key typed while a terminal's find field has the focus.
/// A key typed while a find field has the focus.
static func routeInField(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) -> FieldRoute {
let modifiers = modifierFlags.intersection([.command, .option, .control, .shift])
guard modifiers.contains(.command) else { return .field }
Expand Down
7 changes: 4 additions & 3 deletions Sources/Nirux/Input/WebContentKeyRouting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ enum WebContentKeyRouting {
modifiers == [.command] || modifiers == [.command, .shift] {
return true
}
// Cmd+F, Cmd+G, Shift+Cmd+G: Edit > Find in Terminal and Find
// Next/Previous only act on terminal columns. Monaco's find widget
// and web apps' own search keep these chords.
// Cmd+F, Cmd+G, Shift+Cmd+G: Monaco's find widget and web apps'
// own search keep these chords. A page that leaves them unhandled
// hands them back (WebKit resends the key), and Edit > Find opens
// a browser column's find bar, as in Safari.
if types("f", ansiKeyCode: 0x03), modifiers == [.command] {
return true
}
Expand Down
11 changes: 6 additions & 5 deletions Sources/Nirux/Model/ColumnState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,10 @@ final class ColumnState {
/// The user asked to resume the agent that died mid-turn.
var onResumeExitedAgent: (() -> Void)?

/// ⌘F find bar and the search it drives, created on first use
/// (ColumnState+TerminalFind.swift).
var findBar: TerminalFindBar?
/// ⌘F find bar, created on first use, and the terminal search it
/// drives; a browser column's bar drives its `pageFind`
/// (ColumnState+Find.swift).
var findBar: FindBar?
var terminalSearch: TerminalSearchSession?

/// Claude session transcript this column follows, its latest usage and
Expand All @@ -138,8 +139,8 @@ final class ColumnState {
private static func terminalMenu() -> NSMenu {
let menu = NSMenu()
menu.addItem(ColumnHeaderView.columnItem(
"Find in Terminal…", mainMenuAction: #selector(NiruxApp.showTerminalFind(_:))
) { $0.showTerminalFind() })
"Find in Terminal…", mainMenuAction: #selector(NiruxApp.showFind(_:))
) { $0.showFind() })
menu.addItem(.separator())
ColumnHeaderView.columnMenuItems().forEach(menu.addItem)
return menu
Expand Down
8 changes: 4 additions & 4 deletions Sources/Nirux/NiruxApp+KeyHandling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ extension NiruxApp {
let col = workspace.columns[safe: workspace.focusedIndex]
else { return event }
// Don't consume modifiers for web content columns, or while a
// terminal's find field is typing.
// find field is typing.
if Self.takesWebContentKeys(col) || workspace.findFieldColumn != nil { return event }
return nil
}
}

/// A key typed while a terminal's find field has the focus edits the
/// field, never a PTY (see TerminalFindKeyRouting).
/// A key typed while a find field has the focus edits the field, never
/// a PTY or page (see TerminalFindKeyRouting).
private static func routeFindFieldKey(_ event: NSEvent, in column: ColumnState) -> NSEvent? {
switch TerminalFindKeyRouting.routeInField(keyCode: event.keyCode, modifierFlags: event.modifierFlags) {
case .field:
Expand Down Expand Up @@ -202,7 +202,7 @@ extension NiruxApp {
let col = workspace.columns[safe: workspace.focusedIndex]
else { return event }

// Keys follow a terminal's find field while it has the focus,
// Keys follow a find field while it has the focus,
// even when the focused column changed without moving the
// keyboard (a resize drag, an agent opening an editor column).
if let findColumn = workspace.findFieldColumn {
Expand Down
39 changes: 24 additions & 15 deletions Sources/Nirux/NiruxApp+MenuActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,31 @@ extension NiruxApp {
shell?.showGlobalSearch()
}

// The find items act on the main window's focused terminal column only:
// with a panel or a detached Web Inspector key, the bar would open
// behind it.
@objc func showTerminalFind(_ sender: Any?) {
guard NSApp.keyWindow === mainWindow else { return }
shell?.showTerminalFind()
// The find items act on the main window's focused terminal or browser
// column only: with a panel or a detached Web Inspector key, the bar
// would open behind it.
@objc func showFind(_ sender: Any?) {
guard canFind else { return }
shell?.showFind()
}

@objc func findNextInTerminal(_ sender: Any?) {
guard NSApp.keyWindow === mainWindow else { return }
shell?.findNextInTerminal()
@objc func findNextMatch(_ sender: Any?) {
guard canFind else { return }
shell?.findNextMatch()
}

@objc func findPreviousInTerminal(_ sender: Any?) {
guard NSApp.keyWindow === mainWindow else { return }
shell?.findPreviousInTerminal()
@objc func findPreviousMatch(_ sender: Any?) {
guard canFind else { return }
shell?.findPreviousMatch()
}

/// The find items' state: no other window of the app is key (as Close
/// Column reads it), the main window isn't in the Dock, and a column can
/// search. Editor columns keep ⌘F for Monaco's find widget, which gets it
/// first.
var canFind: Bool {
let isMainWindowInFront = NSApp.keyWindow.map { $0 === mainWindow } ?? (mainWindow?.isMiniaturized != true)
return isMainWindowInFront && shell?.findTargetColumn != nil
}

@objc func toggleEditorDiff(_ sender: Any?) {
Expand Down Expand Up @@ -212,11 +221,11 @@ extension NiruxApp {
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
editMenu.addItem(NSMenuItem.separator())
editMenu.addItem(withTitle: "Find in Terminal…", action: #selector(showTerminalFind(_:)), keyEquivalent: "f")
editMenu.addItem(withTitle: "Find Next", action: #selector(findNextInTerminal(_:)), keyEquivalent: "g")
editMenu.addItem(withTitle: "Find…", action: #selector(showFind(_:)), keyEquivalent: "f")
editMenu.addItem(withTitle: "Find Next", action: #selector(findNextMatch(_:)), keyEquivalent: "g")
let findPreviousItem = editMenu.addItem(
withTitle: "Find Previous",
action: #selector(findPreviousInTerminal(_:)),
action: #selector(findPreviousMatch(_:)),
keyEquivalent: "g"
)
findPreviousItem.keyEquivalentModifierMask = [.command, .shift]
Expand Down
3 changes: 3 additions & 0 deletions Sources/Nirux/NiruxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ final class NiruxApp: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, NSMen
if menuItem.action == #selector(resumeAllAgents(_:)) {
return (shell?.deferredAgentCount ?? 0) > 0
}
if [#selector(showFind(_:)), #selector(findNextMatch(_:)), #selector(findPreviousMatch(_:))].contains(menuItem.action) {
return canFind
}
if menuItem.action == #selector(toggleInactiveWorkspaces(_:)) {
guard let shell else { return false }
menuItem.state = shell.sidebar.isInactiveSectionCollapsed ? .off : .on
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,39 @@
import AppKit

// MARK: - Terminal find bar (⌘F, ⌘G, ⇧⌘G)
// MARK: - Find bar (⌘F, ⌘G, ⇧⌘G) of terminal and browser columns

extension ColumnState {
var isFindBarOpen: Bool { findBar.map { !$0.isHidden } ?? false }

/// True while the find field has the keyboard focus: the key
/// interceptor must then leave keys to the field, not the PTY.
/// interceptor must then leave keys to the field, not the PTY or page.
var isEditingFind: Bool { isFindBarOpen && findBar?.isEditing == true }

/// A browser column, or a terminal whose shell runs: a shell-exited
/// overlay hides the scrollback.
var canFind: Bool {
isWebView || (terminalView != nil && pty?.hasExited != true)
}

/// ⌘F: open the find bar, or focus it again with its text selected.
/// Reopening searches the kept text again. Not over a shell-exited
/// overlay, which hides the scrollback.
/// Reopening searches the kept text again, once the field has the
/// keyboard: a page that loses it drops its selection, so its search
/// starts over from the top.
func showFindBar() {
guard terminalView != nil, pty?.hasExited != true else { return }
guard canFind else { return }
let bar = findBar ?? makeFindBar()
// Above the terminal and a shell-exited overlay added since.
// Above the terminal or page, and a shell-exited overlay added since.
if view.subviews.last !== bar {
view.addSubview(bar, positioned: .above, relativeTo: nil)
}
if bar.isHidden {
bar.isHidden = false
terminalSearch?.update(bar.field.stringValue, immediately: true)
}
let reopens = bar.isHidden
bar.isHidden = false
layoutFindBar()
bar.focusField()
if reopens {
terminalSearch?.update(bar.field.stringValue, immediately: true)
webViewColumn?.pageFind.update(bar.field.stringValue)
}
}

/// Search Everywhere's pick: the find bar opened on `needle` as a new
Expand Down Expand Up @@ -53,59 +62,70 @@ extension ColumnState {
func findNext() {
guard isFindBarOpen else { return }
terminalSearch?.next()
webViewColumn?.pageFind.next()
}

/// ⇧⌘G / Shift+Return: previous match. Only while the bar is open.
func findPrevious() {
guard isFindBarOpen else { return }
terminalSearch?.previous()
webViewColumn?.pageFind.previous()
}

/// Escape or the close button: end the search (clearing Ghostty's
/// highlights), hide the bar and hand the keyboard back to the terminal.
/// The field keeps its text for the next ⌘F, the viewport its position
/// until the next keystroke (TerminalSearchSession.returnToPrompt).
/// Escape or the close button: end the search (clearing the
/// highlights), hide the bar and hand the keyboard back to the terminal
/// or the page. The field keeps its text for the next ⌘F, a terminal's
/// viewport its position until the next keystroke
/// (TerminalSearchSession.returnToPrompt), a page its selected match.
func closeFindBar() {
guard let bar = findBar, !bar.isHidden else { return }
let wasEditing = bar.isEditing
terminalSearch?.end()
webViewColumn?.pageFind.end()
bar.isHidden = true
if wasEditing, let terminal = terminalView {
terminal.window?.makeFirstResponder(terminal)
let content: NSView? = isWebView ? webViewColumn?.webView : terminalView
if wasEditing, let content {
content.window?.makeFirstResponder(content)
}
}

/// Top-right corner of the terminal, under the header.
/// Top-right corner of the terminal or page, under the header.
func layoutFindBar() {
guard let bar = findBar, !bar.isHidden else { return }
let margin: CGFloat = 10
let width = min(TerminalFindBar.preferredWidth, max(0, view.bounds.width - margin * 2))
// A browser column draws its header itself.
let top = isWebView ? ColumnHeaderView.height : headerHeight
let width = min(FindBar.preferredWidth, max(0, view.bounds.width - margin * 2))
bar.frame = NSRect(
x: view.bounds.width - margin - width,
y: view.bounds.height - headerHeight - margin - TerminalFindBar.height,
y: view.bounds.height - top - margin - FindBar.height,
width: width,
height: TerminalFindBar.height
height: FindBar.height
)
}

private func makeFindBar() -> TerminalFindBar {
let bar = TerminalFindBar(frame: .zero)
private func makeFindBar() -> FindBar {
let bar = FindBar(target: isWebView ? .page : .terminal)
bar.isHidden = true
let search = TerminalSearchSession { [weak self] command in
_ = self?.terminalView?.performBindingAction(command.bindingAction)
if let browser = webViewColumn {
browser.pageFind.onStatusChange = { [weak bar] status in bar?.status = status }
bar.onNeedleChange = { [weak browser] text in browser?.pageFind.update(text) }
} else {
terminalSearch = TerminalSearchSession { [weak self] command in
_ = self?.terminalView?.performBindingAction(command.bindingAction)
}
bar.onNeedleChange = { [weak self] text in self?.terminalSearch?.update(text) }
}
bar.onNeedleChange = { [weak self] text in self?.terminalSearch?.update(text) }
bar.onNext = { [weak self] in self?.findNext() }
bar.onPrevious = { [weak self] in self?.findPrevious() }
bar.onClose = { [weak self] in self?.closeFindBar() }
findBar = bar
terminalSearch = search
return bar
}
}

extension WorkspaceState {
/// The terminal column whose find field has the keyboard focus, if any.
/// The column whose find field has the keyboard focus, if any.
/// Usually the focused column, but a resize drag or an agent opening an
/// editor column moves the focus index without moving the keyboard.
var findFieldColumn: ColumnState? {
Expand All @@ -115,28 +135,26 @@ extension WorkspaceState {

extension NiruxShellView {
/// The column Edit > Find acts on: the one whose find field is being
/// edited, else the focused column when it is a terminal.
/// edited, else the focused column when it can search (`canFind`).
var findTargetColumn: ColumnState? {
guard let workspace = activeWorkspace else { return nil }
if let column = workspace.findFieldColumn { return column }
guard let column = workspace.columns[safe: workspace.focusedIndex],
column.terminalView != nil
else { return nil }
guard let column = workspace.columns[safe: workspace.focusedIndex], column.canFind else { return nil }
return column
}

/// Edit > Find in Terminal (⌘F). No-op unless a terminal column has the
/// focus: editor and browser columns get ⌘F themselves
/// Edit > Find (⌘F). No-op unless a terminal or browser column has the
/// focus: editor columns keep ⌘F for Monaco's find widget
/// (WebContentKeyRouting).
func showTerminalFind() {
func showFind() {
findTargetColumn?.showFindBar()
}

func findNextInTerminal() {
func findNextMatch() {
findTargetColumn?.findNext()
}

func findPreviousInTerminal() {
func findPreviousMatch() {
findTargetColumn?.findPrevious()
}
}
Loading
Loading