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
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ src/

## Key behavior

- Panes are **readonly by default** — keyboard input is not forwarded to processes
- Arrow keys (Up/Down) navigate between tabs, PageUp/PageDown scroll by page, Home/End to top/bottom
- Panes are **readonly by default** — keyboard input is not forwarded to processes. Press `Enter` to enter **input mode** (forwards keystrokes to the process for y/n prompts etc.), `Escape` to exit
- Left/Right arrows cycle tabs, Up/Down arrows scroll by line, Shift+Up/Down scroll to top/bottom, PageUp/PageDown scroll by page, Home/End to top/bottom
- Mouse drag selects text and auto-copies to clipboard (OSC 52); `Y` key also copies selection
- `T` toggles an `HH:mm:ss.SSS` timestamp gutter in TUI mode; also enabled via `timestamps: true` config or `--timestamps` flag. Accepts a format string (e.g. `timestamps: "HH:mm:ss"`) with tokens: `YYYY`, `MM`, `DD`, `HH`, `hh`, `mm`, `ss`, `SSS`, `A`
- Keybinding hints are shown in the status bar; config lives in `src/ui/keybindings.ts`
- Compact keybinding hints in the status bar; `H` or `?` opens a full help overlay. Config lives in `src/ui/keybindings.ts`
- Set `interactive: true` on processes that need stdin (REPLs, shells)
- Non-interactive panes hide the terminal cursor
- Non-interactive panes hide the terminal cursor (shown during input mode)
- Set `errorMatcher: true` to detect ANSI red output, or a regex string to match custom patterns — shows a red indicator on the tab while the process keeps running
- `readyPattern` accepts `string` (simple match) or `RegExp` (match + capture groups). RegExp captures are expanded into dependent `command` and `env` values via `$dep.group` syntax (e.g. `$odoo.url`)
- `optional: true` makes a process visible as a tab but not auto-started (starts in `stopped` state). Alt+S starts it manually. Unlike `condition`, optional does not cascade to dependents
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,14 +477,19 @@ Keybindings are shown in the status bar at the bottom of the app. Panes are read
<!-- generated:keybindings -->
| Key | Action |
|-----|--------|
| `←`/`→` or `1`-`9` | Tabs |
| `G/Shift+G` | Top/bottom |
| `←`/`→` or `1`-`9` | Switch tabs |
| `Enter` | Input mode |
| `F` | Search |
| `R` | Restart |
| `Shift+R` | Restart all |
| `S` | Stop/start |
| `F` | Search |
| `Y` | Copy all |
| `L` | Clear |
| `T` | Timestamps |
| `↑↓` | Scroll line |
| `Shift+↑↓` | Top/bottom |
| `G/Shift+G` | Top/bottom |
| `PgUp/PgDn` | Scroll page |
| `O` | Open logs |
| `Ctrl+Click` | Open link |
| `Ctrl+C` | Quit |
Expand Down
2 changes: 1 addition & 1 deletion conductor.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"scripts": {
"setup": "bun install",
"setup": "bun install && bun run docs",
"run": "bun run dev",
"archive": "rm -rf node_modules"
},
Expand Down
5 changes: 3 additions & 2 deletions scripts/generate-docs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { FLAGS, SUBCOMMANDS } from '../src/cli-flags'
import { STATUS_HINTS } from '../src/ui/keybindings'
import { STATUS_HINTS_FULL, toHintPair } from '../src/ui/keybindings'
import { STATUS_ICONS } from '../src/ui/tabs'

const ROOT = join(import.meta.dir, '..')
Expand Down Expand Up @@ -36,7 +36,8 @@ function generateSubcommandsBlock(): string {

function generateKeybindingsTable(): string {
const rows: string[] = ['| Key | Action |', '|-----|--------|']
for (const [label, desc] of STATUS_HINTS) {
for (const hint of STATUS_HINTS_FULL) {
const [label, desc] = toHintPair(hint)
const key = label === '\u2190\u2192/1-9' ? '`\u2190`/`\u2192` or `1`-`9`' : `\`${label}\``
const action = desc.charAt(0).toUpperCase() + desc.slice(1)
rows.push(`| ${key} | ${action} |`)
Expand Down
4 changes: 1 addition & 3 deletions src/process/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,6 @@ export class ProcessRunner {
}

write(data: string): void {
if (this.config.interactive && this.proc?.terminal) {
this.proc.terminal.write(data)
}
this.proc?.terminal?.write(data)
}
}
89 changes: 87 additions & 2 deletions src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { KeyEvent, ResolvedNumuxConfig } from '../types'
import { buildProcessHexColorMap } from '../utils/color'
import type { LogWriter } from '../utils/log-writer'
import { log } from '../utils/logger'
import { HelpOverlay } from './help-overlay'
import { SHORTCUTS } from './keybindings'
import { Pane } from './pane'
import { SearchController } from './search'
Expand All @@ -17,8 +18,10 @@ export class App {
private panes = new Map<string, Pane>()
private tabBar!: TabBar
private statusBar!: StatusBar
private helpOverlay!: HelpOverlay
private search!: SearchController
private activePane: string | null = null
private inputMode = false
private destroyed = false
private names: string[]
private termCols = 80
Expand Down Expand Up @@ -93,9 +96,12 @@ export class App {
border: false
})

// Status bar (only visible during search)
// Status bar
this.statusBar = new StatusBar(this.renderer)

// Help overlay (hidden by default)
this.helpOverlay = new HelpOverlay(this.renderer)

// Search controller
this.search = new SearchController({
logWriter: this.logWriter,
Expand Down Expand Up @@ -135,6 +141,7 @@ export class App {
layout.add(contentRow)
layout.add(this.statusBar.renderable)
this.renderer.root.add(layout)
this.renderer.root.add(this.helpOverlay.renderable)

// Wire tab events (mouse clicks)
this.tabBar.onSelect((_index, name) => this.switchPane(name))
Expand Down Expand Up @@ -179,8 +186,16 @@ export class App {
this.renderer.keyInput.on('keypress', (key: KeyEvent) => {
log(key)

// Ctrl+C: quit (always works)
// Ctrl+C: quit (always works, except in input mode where it goes to process)
if (key.ctrl && key.name === 'c') {
if (this.helpOverlay.isVisible) {
this.helpOverlay.hide()
return
}
if (this.inputMode) {
this.exitInputMode()
return
}
if (this.search.isActive) {
this.search.exit()
return
Expand All @@ -191,12 +206,32 @@ export class App {
return
}

// Help overlay: ? toggles, Esc closes
if (this.helpOverlay.isVisible) {
if (key.name === 'escape' || key.sequence === '?' || key.name === 'h') {
this.helpOverlay.hide()
}
return
}

// Search mode input handling
if (this.search.isActive) {
this.search.handleInput(key)
return
}

// Input mode: forward keys to process, Escape exits
if (this.inputMode && this.activePane) {
if (key.name === 'escape') {
this.exitInputMode()
return
}
if (key.sequence) {
this.manager.write(this.activePane, key.sequence)
}
return
}

if (!this.activePane) return

const isInteractive = this.config.processes[this.activePane]?.interactive === true
Expand All @@ -205,6 +240,18 @@ export class App {
if (!isInteractive) {
const name = key.name.toLowerCase()

// ?/H shows help overlay
if (key.sequence === '?' || name === 'h') {
this.helpOverlay.toggle()
return
}

// Enter: enter input mode
if (name === 'return') {
this.enterInputMode()
return
}

if (key.shift && name === SHORTCUTS.scrollToBottom.key) {
this.panes.get(this.activePane)?.scrollToBottom()
return
Expand Down Expand Up @@ -286,6 +333,17 @@ export class App {
return
}

// Up/Down: scroll by line, Shift+Up/Down: scroll to top/bottom
if (name === 'up' || name === 'down') {
const pane = this.panes.get(this.activePane)
if (key.shift) {
name === 'up' ? pane?.scrollToTop() : pane?.scrollToBottom()
} else {
pane?.scrollBy(name === 'up' ? -1 : 1)
}
return
}

// PageUp/PageDown: scroll by page
if (name === 'pageup' || name === 'pagedown') {
const pane = this.panes.get(this.activePane)
Expand Down Expand Up @@ -322,8 +380,35 @@ export class App {
await this.manager.startAll(termCols, termRows)
}

private enterInputMode(): void {
this.inputMode = true
this.statusBar.setInputMode(true)
// Show cursor in active pane while in input mode
if (this.activePane) {
const pane = this.panes.get(this.activePane)
if (pane) pane.terminal.showCursor = true
}
}

private exitInputMode(): void {
this.inputMode = false
this.statusBar.setInputMode(false)
// Hide cursor again unless process is natively interactive
if (this.activePane) {
const isInteractive = this.config.processes[this.activePane]?.interactive === true
if (!isInteractive) {
const pane = this.panes.get(this.activePane)
if (pane) pane.terminal.showCursor = false
}
}
}

private switchPane(name: string): void {
if (this.activePane === name) return
// Exit input mode on pane switch
if (this.inputMode) {
this.exitInputMode()
}
// In single-pane search mode, exit search on pane switch
if (this.search.isActive && !this.search.isAllMode) {
this.search.exit()
Expand Down
79 changes: 79 additions & 0 deletions src/ui/help-overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { BoxRenderable, type CliRenderer, TextRenderable } from '@opentui/core'
import { STATUS_HINTS_FULL, toHintPair } from './keybindings'

export class HelpOverlay {
readonly renderable: BoxRenderable
private textRenderable: TextRenderable

constructor(renderer: CliRenderer) {
this.renderable = new BoxRenderable(renderer, {
id: 'help-overlay',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: 100,
visible: false,
justifyContent: 'center',
alignItems: 'center'
})

// Semi-transparent backdrop
const backdrop = new BoxRenderable(renderer, {
id: 'help-backdrop',
position: 'absolute',
width: '100%',
height: '100%',
backgroundColor: '#000000',
opacity: 0.7
})

// Content box
const box = new BoxRenderable(renderer, {
id: 'help-box',
flexDirection: 'column',
padding: 1,
paddingX: 5,
backgroundColor: '#1a1a2e',
border: true,
borderColor: '#444',
zIndex: 101
})

const lines: string[] = [
'Keyboard Shortcuts',
'',
...STATUS_HINTS_FULL.map(h => {
const [label, desc] = toHintPair(h)
return ` ${label.padEnd(14)} ${desc}`
}),
'',
'Press H or Esc to close'
]

this.textRenderable = new TextRenderable(renderer, {
id: 'help-text',
content: lines.join('\n'),
fg: '#cccccc'
})

box.add(this.textRenderable)
this.renderable.add(backdrop)
this.renderable.add(box)
}

get isVisible(): boolean {
return this.renderable.visible
}

toggle(): void {
this.renderable.visible = !this.renderable.visible
}

hide(): void {
this.renderable.visible = false
}

show(): void {
this.renderable.visible = true
}
}
45 changes: 34 additions & 11 deletions src/ui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,42 @@ export const SHORTCUTS = {
openLogs: { key: 'o', label: 'O', description: 'open logs' }
} as const satisfies Record<string, Shortcut>

/** Hints shown in the status bar (subset + navigation keys) */
export const STATUS_HINTS: [label: string, description: string][] = [
['\u2190\u2192/1-9', 'tabs'],
type Hint = Shortcut | [label: string, description: string]

export function toHintPair(hint: Hint): [string, string] {
return Array.isArray(hint) ? hint : [hint.label, hint.description]
}

/** Compact hints shown in the status bar */
export const STATUS_HINTS_COMPACT: Hint[] = [
['\u2190\u2192', 'tabs'],
SHORTCUTS.search,
SHORTCUTS.copy,
['Enter', 'input'],
['H', 'help']
]

/** Full hints shown in the help overlay */
export const STATUS_HINTS_FULL: Hint[] = [
['\u2190\u2192/1-9', 'switch tabs'],
['Enter', 'input mode'],
SHORTCUTS.search,
SHORTCUTS.restart,
SHORTCUTS.restartAll,
SHORTCUTS.stopStart,
SHORTCUTS.copy,
SHORTCUTS.clear,
SHORTCUTS.timestamps,
['\u2191\u2193', 'scroll line'],
['Shift+\u2191\u2193', 'top/bottom'],
['G/Shift+G', 'top/bottom'],
[SHORTCUTS.restart.label, SHORTCUTS.restart.description],
[SHORTCUTS.stopStart.label, SHORTCUTS.stopStart.description],
[SHORTCUTS.search.label, SHORTCUTS.search.description],
[SHORTCUTS.copy.label, SHORTCUTS.copy.description],
[SHORTCUTS.clear.label, SHORTCUTS.clear.description],
[SHORTCUTS.timestamps.label, SHORTCUTS.timestamps.description],
[SHORTCUTS.openLogs.label, SHORTCUTS.openLogs.description],
['PgUp/PgDn', 'scroll page'],
SHORTCUTS.openLogs,
['Ctrl+Click', 'open link'],
['Ctrl+C', 'quit']
]

export const STATUS_BAR_TEXT = STATUS_HINTS.map(([l, d]) => `${l}: ${d}`).join(' ')
export const STATUS_BAR_TEXT = STATUS_HINTS_COMPACT.map(h => {
const [l, d] = toHintPair(h)
return `${l}: ${d}`
}).join(' ')
Loading
Loading