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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export default defineConfig({
<!-- generated:options -->
| Flag | Description |
|------|-------------|
| `-s,` `--sort` `<config|alphabetical|topological>` | Tab display order |
| `-s,` `--sort` `<config|alphabetical|topological|status>` | Tab display order |
| `-w,` `--workspace` `<script>` | Run a package.json script across all workspaces |
| `-n,` `--name` `<name=command>` | Add a named process |
| `-c,` `--color` `<colors>` | Comma-separated colors (hex or names: black, red, green, yellow, blue, magenta, cyan, white, gray, orange, purple) |
Expand Down Expand Up @@ -277,7 +277,7 @@ Top-level options apply to all processes (process-level settings override):
| `stopSignal` | `'SIGTERM' \| 'SIGINT' \| 'SIGHUP'` | Global stop signal, inherited by all processes |
| `errorMatcher` | `boolean \| string` | Global error matcher, inherited by all processes. `true` = detect ANSI red output, string = regex |
| `watch` | `string \| string[]` | Global watch patterns, inherited by processes without their own watch |
| `sort` | `'config' \| 'alphabetical' \| 'topological'` | Tab display order. `'config'` preserves definition order (package.json script order for wildcards), `'alphabetical'` sorts by process name, `'topological'` sorts by dependency tiers. |
| `sort` | `'config' \| 'alphabetical' \| 'topological' \| 'status'` | Tab display order. `'config'` preserves definition order (package.json script order for wildcards), `'alphabetical'` sorts by process name, `'topological'` sorts by dependency tiers, `'status'` uses config order but moves finished/stopped/failed/skipped tabs to the bottom. |
| `prefix` | `boolean` | Use prefixed output mode instead of TUI (for CI/scripts) |
| `timestamps` | `boolean \| string` | Add timestamps to output lines. `true` uses default `HH:mm:ss.SSS` format, or pass a format string (e.g. `"HH:mm:ss"`) |
| `killOthers` | `boolean` | Kill all processes when any one exits (regardless of exit code) |
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ interface FieldDoc {

/** Known type aliases to expand for readability */
const TYPE_ALIASES: Record<string, string> = {
SortOrder: "'config' | 'alphabetical' | 'topological'",
SortOrder: "'config' | 'alphabetical' | 'topological' | 'status'",
Color: 'string'
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const FLAGS: FlagDef[] = [
short: '-s',
key: 'sort',
description: 'Tab display order',
valueName: '<config|alphabetical|topological>',
valueName: '<config|alphabetical|topological|status>',
completionHint: 'none'
},
{
Expand Down
8 changes: 8 additions & 0 deletions src/config/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,14 @@ describe('validateConfig — sort', () => {
expect(config.sort).toBe('topological')
})

test('preserves sort: status', () => {
const config = validateConfig({
sort: 'status',
processes: { a: { command: 'echo a' } }
})
expect(config.sort).toBe('status')
})

test('throws on invalid sort value', () => {
expect(() =>
validateConfig({
Expand Down
2 changes: 1 addition & 1 deletion src/config/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ function validateErrorMatcher(name: string, value: unknown): boolean | string |
return undefined
}

const VALID_SORT_VALUES = new Set(['config', 'alphabetical', 'topological'])
const VALID_SORT_VALUES = new Set(['config', 'alphabetical', 'topological', 'status'])

function validateSort(value: unknown): SortOrder | undefined {
if (typeof value === 'string') {
Expand Down
13 changes: 13 additions & 0 deletions src/process/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ describe('ProcessManager — initialization', () => {
expect(names.indexOf('db')).toBeLessThan(names.indexOf('api'))
expect(names.indexOf('api')).toBeLessThan(names.indexOf('web'))
})

test('sort: status returns config order (reorder is applied in TabBar)', () => {
const config: ResolvedNumuxConfig = {
sort: 'status',
processes: {
web: { command: 'echo web' },
api: { command: 'echo api' },
db: { command: 'echo db' }
}
}
const mgr = new ProcessManager(config)
expect(mgr.getProcessNames()).toEqual(['web', 'api', 'db'])
})
})

describe('ProcessManager — startAll', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ export interface NumuxConfig<K extends string = string> {
watch?: string | string[]
/**
* Tab display order. `'config'` preserves definition order (package.json script order for wildcards),
* `'alphabetical'` sorts by process name, `'topological'` sorts by dependency tiers.
* `'alphabetical'` sorts by process name, `'topological'` sorts by dependency tiers,
* `'status'` uses config order but moves finished/stopped/failed/skipped tabs to the bottom.
* @default 'config'
*/
sort?: SortOrder
Expand Down Expand Up @@ -125,7 +126,7 @@ export interface NumuxConfig<K extends string = string> {
processes: Record<K, NumuxProcessConfig<K> | NumuxScriptPattern<K> | string | true>
}

export type SortOrder = 'config' | 'alphabetical' | 'topological'
export type SortOrder = 'config' | 'alphabetical' | 'topological' | 'status'

/** Process config after validation — dependsOn is always normalized to an array */
export interface ResolvedProcessConfig extends Omit<NumuxProcessConfig, 'dependsOn' | 'workspaces'> {
Expand Down
2 changes: 1 addition & 1 deletion src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class App {

// Tab bar (vertical sidebar)
const processHexColors = buildProcessHexColorMap(this.names, this.config)
this.tabBar = new TabBar(this.renderer, this.names, processHexColors)
this.tabBar = new TabBar(this.renderer, this.names, processHexColors, this.config.sort === 'status')

// Content row: sidebar | pane
const contentRow = new BoxRenderable(this.renderer, {
Expand Down
39 changes: 18 additions & 21 deletions src/ui/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export const STATUS_ICON_HEX: Partial<Record<ProcessStatus, string>> = {
/** Statuses that represent a terminal (done) state — tabs move to bottom */
export const TERMINAL_STATUSES = new Set<ProcessStatus>(['finished', 'stopped', 'failed', 'skipped'])

export function getDisplayOrder(originalNames: string[], statuses: Map<string, ProcessStatus>): string[] {
const active = originalNames.filter(n => !TERMINAL_STATUSES.has(statuses.get(n)!))
const terminal = originalNames.filter(n => TERMINAL_STATUSES.has(statuses.get(n)!))
return [...active, ...terminal]
}

export function formatTab(name: string, status: ProcessStatus): string {
return `${STATUS_ICONS[status]} ${name}`
}
Expand All @@ -48,12 +54,6 @@ export function formatDescription(status: ProcessStatus, exitCode?: number | nul
return desc
}

export function getDisplayOrder(originalNames: string[], statuses: Map<string, ProcessStatus>): string[] {
const active = originalNames.filter(n => !TERMINAL_STATUSES.has(statuses.get(n)!))
const terminal = originalNames.filter(n => TERMINAL_STATUSES.has(statuses.get(n)!))
return [...active, ...terminal]
}

export function resolveOptionColors(
names: string[],
statuses: Map<string, ProcessStatus>,
Expand Down Expand Up @@ -182,13 +182,15 @@ export class TabBar {
private statuses: Map<string, ProcessStatus>
private baseDescriptions: Map<string, string>
private processColors: Map<string, string>
private reorderByStatus: boolean
private inputWaiting = new Set<string>()
private erroredProcesses = new Set<string>()
private searchMatchCounts = new Map<string, number>()

constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>) {
constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>, reorderByStatus = false) {
this.originalNames = names
this.names = [...names]
this.reorderByStatus = reorderByStatus
this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
this.baseDescriptions = new Map(names.map(n => [n, 'pending']))
this.processColors = colors ?? new Map()
Expand Down Expand Up @@ -270,24 +272,19 @@ export class TabBar {
}

private refreshOptions(): void {
// Preserve currently selected name
const currentIdx = this.renderable.getSelectedIndex()
const currentName = this.names[currentIdx]

// Reorder: active first, terminal states at bottom
this.names = getDisplayOrder(this.originalNames, this.statuses)

if (this.reorderByStatus) {
const currentIdx = this.renderable.getSelectedIndex()
const currentName = this.names[currentIdx]
this.names = getDisplayOrder(this.originalNames, this.statuses)
const newIdx = this.names.indexOf(currentName)
if (newIdx >= 0 && newIdx !== currentIdx) {
this.renderable.setSelectedIndex(newIdx)
}
}
this.renderable.options = this.names.map(n => ({
name: formatTab(n, this.statuses.get(n)!),
description: this.getDescription(n)
}))

// Restore selection by name
const newIdx = this.names.indexOf(currentName)
if (newIdx >= 0 && newIdx !== currentIdx) {
this.renderable.setSelectedIndex(newIdx)
}

this.updateOptionColors()
}

Expand Down
Loading