From 913970564448c9ce91a9448b11c186f2147b5482 Mon Sep 17 00:00:00 2001 From: Eivind Date: Thu, 16 Apr 2026 15:39:34 +0200 Subject: [PATCH 1/3] feat(ui): compact footer, help overlay, and input mode Redesign status bar to show only key shortcuts with H/? for a full help overlay. Add Enter key to enter input mode (forwards keystrokes to the process for y/n prompts), Escape to exit. Add Up/Down arrow scrolling and Shift+Up/Down for top/bottom. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 8 ++-- README.md | 12 ++++-- src/process/runner.ts | 4 +- src/ui/app.ts | 89 +++++++++++++++++++++++++++++++++++++++++- src/ui/help-overlay.ts | 79 +++++++++++++++++++++++++++++++++++++ src/ui/keybindings.ts | 45 +++++++++++++++------ src/ui/status-bar.ts | 48 ++++++++++++++++++----- 7 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 src/ui/help-overlay.ts diff --git a/CLAUDE.md b/CLAUDE.md index ad9d043..6e0866b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` timestamp gutter in TUI mode; also enabled via `timestamps: true` config or `--timestamps` flag. Accepts a format string (e.g. `timestamps: "HH:mm:ss.SSS"`) 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 diff --git a/README.md b/README.md index eb9ba02..1d7058a 100644 --- a/README.md +++ b/README.md @@ -477,15 +477,21 @@ Keybindings are shown in the status bar at the bottom of the app. Panes are read | 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+↑`/`Shift+↓` | Scroll to top/bottom | +| `G`/`Shift+G` | Scroll to top/bottom | +| `PgUp`/`PgDn` | Scroll page | | `O` | Open logs | +| `H` or `?` | Help overlay | | `Ctrl+Click` | Open link | | `Ctrl+C` | Quit | diff --git a/src/process/runner.ts b/src/process/runner.ts index 2805c50..69fbb91 100644 --- a/src/process/runner.ts +++ b/src/process/runner.ts @@ -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) } } diff --git a/src/ui/app.ts b/src/ui/app.ts index 145f3a0..3fc9fb8 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -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' @@ -17,8 +18,10 @@ export class App { private panes = new Map() 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 @@ -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, @@ -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)) @@ -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 @@ -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 @@ -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 @@ -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) @@ -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() diff --git a/src/ui/help-overlay.ts b/src/ui/help-overlay.ts new file mode 100644 index 0000000..07dbb7d --- /dev/null +++ b/src/ui/help-overlay.ts @@ -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 + } +} diff --git a/src/ui/keybindings.ts b/src/ui/keybindings.ts index 62aff5b..228ff5e 100644 --- a/src/ui/keybindings.ts +++ b/src/ui/keybindings.ts @@ -19,19 +19,42 @@ export const SHORTCUTS = { openLogs: { key: 'o', label: 'O', description: 'open logs' } } as const satisfies Record -/** 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(' ') diff --git a/src/ui/status-bar.ts b/src/ui/status-bar.ts index 876505f..27a97ac 100644 --- a/src/ui/status-bar.ts +++ b/src/ui/status-bar.ts @@ -1,4 +1,14 @@ -import { type CliRenderer, cyan, red, reverse, StyledText, type TextChunk, TextRenderable, yellow } from '@opentui/core' +import { + BoxRenderable, + type CliRenderer, + cyan, + red, + reverse, + StyledText, + type TextChunk, + TextRenderable, + yellow +} from '@opentui/core' import { STATUS_BAR_TEXT } from './keybindings' function plain(text: string): TextChunk { @@ -6,7 +16,8 @@ function plain(text: string): TextChunk { } export class StatusBar { - readonly renderable: TextRenderable + readonly renderable: BoxRenderable + private text: TextRenderable private _searchMode = false private _searchQuery = '' private _searchMatchCount = 0 @@ -14,17 +25,26 @@ export class StatusBar { private _crossProcessInfo?: { totalMatches: number; processCount: number } private _tempMessage: string | null = null private _tempTimer: ReturnType | null = null + private _inputMode = false constructor(renderer: CliRenderer) { - this.renderable = new TextRenderable(renderer, { + this.renderable = new BoxRenderable(renderer, { id: 'status-bar', width: '100%', + backgroundColor: '#1a1a1a', + paddingX: 1, + minHeight: 1 + }) + + this.text = new TextRenderable(renderer, { + id: 'status-bar-text', + width: '100%', wrapMode: 'word', - content: this.buildContent(), - bg: '#1a1a1a', - paddingX: 1 + content: this.buildContent() }) - this.renderable.selectable = false + this.text.selectable = false + + this.renderable.add(this.text) } setSearchMode( @@ -39,20 +59,25 @@ export class StatusBar { this._searchMatchCount = matchCount this._searchCurrentIndex = currentIndex this._crossProcessInfo = crossProcessInfo - this.renderable.content = this.buildContent() + this.text.content = this.buildContent() } showTemporaryMessage(message: string, duration = 2000): void { if (this._tempTimer) clearTimeout(this._tempTimer) this._tempMessage = message - this.renderable.content = this.buildContent() + this.text.content = this.buildContent() this._tempTimer = setTimeout(() => { this._tempMessage = null this._tempTimer = null - this.renderable.content = this.buildContent() + this.text.content = this.buildContent() }, duration) } + setInputMode(active: boolean): void { + this._inputMode = active + this.text.content = this.buildContent() + } + private buildContent(): StyledText { if (this._tempMessage) { return new StyledText([cyan(this._tempMessage)]) @@ -60,6 +85,9 @@ export class StatusBar { if (this._searchMode) { return this.buildSearchContent() } + if (this._inputMode) { + return new StyledText([yellow('INPUT'), plain(' Type to send input to process. '), plain('Esc: exit')]) + } return new StyledText([plain(STATUS_BAR_TEXT)]) } From 006819e959fededa6ddcb5b9e8105fc32ad2a290 Mon Sep 17 00:00:00 2001 From: Eivind Date: Thu, 16 Apr 2026 15:42:33 +0200 Subject: [PATCH 2/3] fix(docs): update generate-docs to use Hint type from keybindings Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 9 ++++----- scripts/generate-docs.ts | 5 +++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1d7058a..7c8412c 100644 --- a/README.md +++ b/README.md @@ -486,12 +486,11 @@ Keybindings are shown in the status bar at the bottom of the app. Panes are read | `Y` | Copy all | | `L` | Clear | | `T` | Timestamps | -| `↑`/`↓` | Scroll line | -| `Shift+↑`/`Shift+↓` | Scroll to top/bottom | -| `G`/`Shift+G` | Scroll to top/bottom | -| `PgUp`/`PgDn` | Scroll page | +| `↑↓` | Scroll line | +| `Shift+↑↓` | Top/bottom | +| `G/Shift+G` | Top/bottom | +| `PgUp/PgDn` | Scroll page | | `O` | Open logs | -| `H` or `?` | Help overlay | | `Ctrl+Click` | Open link | | `Ctrl+C` | Quit | diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 7d1f061..fca77e5 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -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, '..') @@ -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} |`) From 148e2b9e7b8d6a635468f694da49448e2e027133 Mon Sep 17 00:00:00 2001 From: Eivind Date: Thu, 16 Apr 2026 15:53:58 +0200 Subject: [PATCH 3/3] chore: add docs generation to conductor setup script Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conductor.json b/conductor.json index fe22279..419bbeb 100644 --- a/conductor.json +++ b/conductor.json @@ -1,6 +1,6 @@ { "scripts": { - "setup": "bun install", + "setup": "bun install && bun run docs", "run": "bun run dev", "archive": "rm -rf node_modules" },