diff --git a/docs/design/background-task-ui.md b/docs/design/background-task-ui.md new file mode 100644 index 00000000..073a3210 --- /dev/null +++ b/docs/design/background-task-ui.md @@ -0,0 +1,108 @@ +# Background tasks: task bar, keys, suggestion, manager and output + +Issue [#382](https://github.com/raiseCatError/notMyShell/issues/382). Core architecture and integration contract: +[background-tasks.md](background-tasks.md). This slice is pure presentation and input logic in `src/backgroundTasks/` +(`bar.ts`, `suggestion.ts`, `view.ts`, `manager.ts`); it performs no I/O and changes nothing in the running app. + +## Task bar (`bar.ts`) + +Frontend chrome directly above the composer, given rows the way notices are (`withNoticeRows`), so it never covers +the composer or the transcript. + +| State | Rows | +| --- | --- | +| No tasks | none: no space, no work | +| Collapsed (default) | one summary: `● 3 background tasks · 2 running · 1 failed` | +| Collapsed, focused | the selected task: `›● adb logcat 04:21 running 1/3 Enter view · x stop · Esc back` | +| Expanded | a header, as many task rows as fit, `+N more · /background lists all`; at most four rows | + +`rows()` and `renderTaskBar` share one layout, so the rows reserved are always the rows drawn. Every state has a +mark and a word (Unicode `● ◐ ✓ ✗ ■`, Safe `* ~ + x -`; `running`, `stopping (SIGTERM sent)`, `exited`, +`failed (exit 1)`, `stopped, processes left`), so color only reinforces. Labels are the command family +(`adb logcat`) or the command's first line, width-aware; durations are `mm:ss` or `h:mm:ss` from the task's own +start and end. The keys shown for the selection are the ones that apply to it (`x stop`, `x terminate (SIGTERM)`, +`x kill what is left`, `Del remove`), fitted to the width by dropping whole hints and keeping `Esc back`. In narrow +rows the label outlasts the timer and the long state words: at 40 columns a stopping task still says which task it is. +Removing the selected task selects its neighbour. + +## Keyboard + +The person asked for Up to reach the bar. Up already recalls history and moves between draft lines, so the rule is +the one NMSh already uses for the agent shelf above the composer: + +- **Up enters the bar only from an idle composer**: empty draft, not browsing history, no foreground command running + or waiting for input, no other owner (passthrough, approval, search, a panel, the agent message box). In every other + state Up keeps its editor and history meaning. The trade-off is the same as the shelf's: with tasks shown, the + first Up from an empty prompt goes to the bar; Ctrl+R and Up after typing still reach history. A preference turns + it off. +- **Shift+Up** from an empty composer always enters the bar (with no draft it has nothing to select). +- **`/background`** (`/bg`) works when the terminal or tmux takes the keys. +- In the bar: ↑↓ select (↓ past the last task returns to the composer below), Enter view output, `x` stop (pressed + again on a task that has not exited: the next escalation, named on screen), Delete remove a finished task, Space + expand/collapse, Esc back. Any other typing leaves the bar and is forwarded to the composer, so nothing typed is + lost. +- **Escalation is paced.** A stronger signal is offered and sent only `ESCALATION_PAUSE_MS` (1.5 s) after the previous + request; before that `x` says to give the program a moment. A double press or a held key's repeat therefore cannot + go from SIGINT to SIGKILL before the program could clean up. `/background kill t3` stays the immediate explicit path. +- **Leftovers.** A finished task whose processes still run offers `x kill what is left` (one SIGKILL to its group) and + cannot be removed until nothing runs, so nothing is orphaned without a way to stop it. `stopChoice` and + `removalBlocked` are the one rule the bar and the manager share. +- Nothing here runs while a foreground program owns input: the frontend only consults the bar for the composer + owner, and `entersBar` refuses when a foreground command is busy. + +Stop, dismiss and remove stay distinct: `x` stops a running task; leaving a view dismisses it and touches no task; +Delete removes a finished task (never a running one). + +## Suggestion (`suggestion.ts`) + +Shown when a submitted command is recognized and its family has no remembered decision. Not a modal: + +``` +↻ adb logcat keeps streaming device logs until it is interrupted. +b background · Enter run here · a always · n don't ask · Esc edit — in the background it runs separately, without this shell's aliases, functions or unexported variables +``` + +Enter runs the command in the foreground exactly as before, so pressing Enter twice is the old behavior. `b` runs +this invocation in the background, `a` remembers Always for the family, `n` remembers Never, Esc returns to the draft +unchanged. "Likely" families add "(it may keep running)". Below 48 columns it compacts to one row. + +`/background run ` is the deliberate path: the text after `run ` is kept byte for byte. Programs that need +the terminal or the person (editors, pagers, `sudo`, `ssh`, `top`, `watch`, …) are refused with the reason; commands +with expansions or lists run, with a note that they run in a separate shell. + +Subcommands: `run`, `stop t3`, `kill t3` (stop with escalation intent), `remove t3`, `clear`, +`suggestions on|off`, `forget `. `/backgrounds` is not this command. + +## Output (`view.ts`) + +- **Transcript block**: NMSh-owned, labelled `Background · adb logcat · t4 · running 04:21`, the latest lines (six by + default), `… N earlier lines (M beyond the retention limit) · /background t4`, the unfinished line while running. + Foreground command blocks never receive task output. `taskBlockText` gives the plain text for copying, labelled + with the task. +- **Focused live view** (Enter on a task): header with label, id, state, duration and directory; follows new output + until the person scrolls back, then stays put and says `N new lines below · End follow`. The scrolled-back position + is the seq of the first line shown, not a row index, so the retention limit dropping old lines does not move what is + being read; if those very lines are dropped, the view shows the oldest kept line and says so. While following a live + task its unfinished line (a progress bar, a prompt without a newline) is the last row. + +## Manager (`manager.ts`) + +Tasks tab: running first, newest first; details (command with a `(+N more lines)` note, directory, start, end and +status, processes left, lines kept and dropped, the separate-shell note); Enter output, `x` stop, Delete remove, `c` +clear finished. The selection is a task id, not a row: tasks finishing or starting while the manager is open reorder +the list, and `x` or Delete still act on the task the person chose. The list keeps at least three rows, scrolls with +the selection and counts the rows above and below it; the details get the remaining height. +Preferences tab: suggestions, bar, Up, notifications, output kept, finished kept, and the remembered families (←→ or +Enter flips Always/Never, Delete forgets). Changes return new preferences for the caller to save. + +## Hostile text + +Commands, families and directory names are not NMSh's words: a pasted command or a directory name can carry escape +sequences. `taskText` removes whole escape sequences, controls and bidi overrides before anything is drawn or copied +(tested with an OSC title, a screen clear and an RTL override in the command and an SGR sequence in the directory). + +## Not in this slice + +Wiring into TerminalApp (submission hook, rows above the composer, key routing, the panel slot, notifications), the +service protocol, and adopting the shared action controls (#369) once it lands. Physical QA: see +[background-tasks.md](background-tasks.md). diff --git a/src/backgroundTasks/bar.ts b/src/backgroundTasks/bar.ts new file mode 100644 index 00000000..e6f4a569 --- /dev/null +++ b/src/backgroundTasks/bar.ts @@ -0,0 +1,289 @@ +import type {Key} from '../terminal/keys.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateText} from '../util/text.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; +import type {StopLevel, TaskRecord, TaskState} from './supervisor.js'; + +/** + * The background task bar (#382): frontend chrome directly above the + * composer. Empty, it takes no rows. Collapsed (the default), it is one + * summary row; expanded, one row per task up to a bound, with an overflow + * row. It never covers the composer or the transcript: the frontend gives it + * rows the way it gives notices rows (ScreenPlan `withNoticeRows`). + * + * Keyboard (decided against the existing composer behavior): + * - Up from an idle composer (empty draft, not browsing history, no + * foreground command running or waiting for input) enters the bar, the + * same spatial rule the agent shelf uses. Otherwise Up keeps its editor and + * history meaning. A preference turns this off. + * - Shift+Up from an empty composer always enters it (with an empty draft + * it has nothing to select), and `/background` works when keys are taken + * by the terminal. + * - In the bar: ↑↓ select, Enter view output, `x` stop (again, after a + * moment: the next escalation, named on screen; on a finished task whose + * processes still run: kill them), Delete remove a finished task, Space + * expand/collapse, Esc back. Any other typing returns to the composer and + * is not lost. + */ + +export type BarAction = + | {kind: 'enter'} + | {kind: 'leave'; forward?: Key} + | {kind: 'select'; delta: number} + | {kind: 'view'; taskId: string} + | {kind: 'stop'; taskId: string; level: StopLevel} + | {kind: 'remove'; taskId: string} + | {kind: 'toggle'} + | {kind: 'none'; message?: string}; + +export interface ComposerState { + /** No draft text and no selection. */ + empty: boolean; + /** Up/Down are moving through shell history right now. */ + browsingHistory: boolean; + /** A foreground command runs (it may be waiting for input): it owns the keys. */ + foregroundBusy: boolean; + /** Another owner (passthrough, approval, search, a panel, the agent message box) has the keyboard. */ + otherOwner: boolean; +} + +/** Whether a key in the composer enters the bar. Never while anything else owns input; never with a draft. */ +export function entersBar(key: Key, composer: ComposerState, tasks: number, upArrow: 'idle' | 'off'): boolean { + if (!tasks || composer.foregroundBusy || composer.otherOwner || !composer.empty) return false; + if (key.kind === 'selectUp') return true; + return key.kind === 'up' && upArrow === 'idle' && !composer.browsingHistory; +} + +const LIVE: ReadonlySet = new Set(['running', 'stopping']); +const LEVEL_WORDS: Record = {interrupt: 'interrupt', terminate: 'terminate (SIGTERM)', kill: 'kill (SIGKILL)'}; + +/** + * How long a stronger signal waits after the previous stop request. Without it a double press, or a held key's + * repeat, went from SIGINT to SIGKILL in a fraction of a second, before the program could clean up. + */ +export const ESCALATION_PAUSE_MS = 1500; + +/** What `x` does for a task now: a stop level to request, or why nothing is sent. The bar and the manager share it. */ +export function stopChoice(task: TaskRecord, next: (taskId: string) => StopLevel | undefined, now: number): {level: StopLevel} | {message: string} { + if (!LIVE.has(task.state)) { + // A finished task whose group still runs: the only stop left is the explicit kill of what remains. + if (task.leftovers) return task.stopRequested === 'kill' ? {message: 'already killed; waiting for its processes to exit'} : {level: 'kill'}; + return {message: 'this task has already ended; Delete removes it from the list'}; + } + const level = next(task.id); + if (!level) return {message: 'already killed; waiting for the process to exit'}; + if (level !== 'interrupt' && task.stopRequestedAt !== undefined && now - task.stopRequestedAt < ESCALATION_PAUSE_MS) { + return {message: `stop requested; give it a moment, then x sends ${LEVEL_WORDS[level]}`}; + } + return {level}; +} + +/** Why a task cannot be removed now, or undefined when it can. */ +export function removalBlocked(task: TaskRecord): string | undefined { + if (LIVE.has(task.state)) return 'stop it first (x); a running task is never removed'; + if (task.leftovers) return 'its processes still run; x kills them, then Delete removes it'; + return undefined; +} + +/** The key hint `x` has for a task now, or '' when it does nothing. */ +function stopHint(task: TaskRecord, next: ((taskId: string) => StopLevel | undefined) | undefined, now: number): string { + if (!next) return LIVE.has(task.state) ? 'x stop' : ''; + const choice = stopChoice(task, next, now); + if (!('level' in choice)) return ''; + if (!LIVE.has(task.state)) return 'x kill what is left'; + return choice.level === 'interrupt' ? 'x stop' : `x ${LEVEL_WORDS[choice.level]}`; +} + +/** + * Key hints joined with ` · `, fitted to a width by dropping hints before the last one (the way back), so a narrow + * terminal keeps the first actions and Esc rather than cutting through the middle of a word. + */ +export function fitHints(hints: readonly string[], width: number): string { + const kept = hints.filter(Boolean); + while (kept.length > 1 && displayWidth(kept.join(' · ')) > width) kept.splice(kept.length - 2, 1); + return truncateText(kept.join(' · '), Math.max(1, width)); +} + +export class TaskBarController { + selectedId?: string; + focused = false; + expanded: boolean; + /** Where the selection was, so removing a task selects its neighbour rather than jumping to the first. */ + private index = 0; + + constructor(expanded = false) { this.expanded = expanded; } + + /** Keep the selection on an existing task (by id); when it is gone, on the task now in its place. */ + sync(tasks: readonly TaskRecord[]): void { + let index = tasks.findIndex(task => task.id === this.selectedId); + if (index === -1) { index = Math.min(this.index, tasks.length - 1); this.selectedId = tasks[index]?.id; } + this.index = Math.max(0, index); + if (!tasks.length) this.focused = false; + } + + /** A key while the bar has focus. `next` is the escalation the supervisor would apply for the selected task. */ + handleKey(key: Key, tasks: readonly TaskRecord[], next: (taskId: string) => StopLevel | undefined, now = Date.now()): BarAction { + this.sync(tasks); + const index = tasks.findIndex(task => task.id === this.selectedId); + const selected = tasks[index]; + switch (key.kind) { + case 'escape': this.focused = false; return {kind: 'leave'}; + case 'up': case 'down': { + const target = Math.max(0, Math.min(tasks.length - 1, index + (key.kind === 'up' ? -1 : 1))); + // Down past the last task goes back to the composer, which sits right below the bar. + if (key.kind === 'down' && index === tasks.length - 1) { this.focused = false; return {kind: 'leave'}; } + this.selectedId = tasks[target]?.id; + return {kind: 'select', delta: key.kind === 'up' ? -1 : 1}; + } + case 'enter': return selected ? {kind: 'view', taskId: selected.id} : {kind: 'none'}; + case 'delete': case 'backspace': { + if (!selected) return {kind: 'none'}; + const blocked = removalBlocked(selected); + return blocked ? {kind: 'none', message: blocked} : {kind: 'remove', taskId: selected.id}; + } + case 'text': { + if (key.value === 'x' && selected) { + const choice = stopChoice(selected, next, now); + return 'level' in choice ? {kind: 'stop', taskId: selected.id, level: choice.level} : {kind: 'none', message: choice.message}; + } + if (key.value === ' ') { this.expanded = !this.expanded; return {kind: 'toggle'}; } + this.focused = false; + return {kind: 'leave', forward: key}; + } + case 'paste': this.focused = false; return {kind: 'leave', forward: key}; + default: return {kind: 'none'}; + } + } + + /** The rows this bar needs now; the frontend reserves exactly these. */ + rows(tasks: readonly TaskRecord[], maxRows = 4): number { + if (!tasks.length) return 0; + if (!this.expanded) return 1; + const layout = expandedLayout(tasks.length, maxRows); + return 1 + layout.visible + Number(layout.overflow); + } +} + +/** Expanded: a header, as many task rows as fit, and an overflow row when some do not; never more than `maxRows`. */ +function expandedLayout(count: number, maxRows: number): {visible: number; overflow: boolean} { + const budget = Math.max(2, maxRows) - 1; + return count <= budget ? {visible: count, overflow: false} : {visible: Math.max(1, budget - 1), overflow: true}; +} + +export function elapsed(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60; + const two = (n: number) => String(n).padStart(2, '0'); + return h ? `${h}:${two(m)}:${two(s)}` : `${two(m)}:${two(s)}`; +} + +/** The state in one word or two, for rows too narrow for `stateWords` (the mark still tells the states apart). */ +function shortState(task: TaskRecord): string { + if (task.state === 'stopping' || task.state === 'failed') return task.state; + return task.leftovers ? `${task.state}, left` : task.state; +} + +/** The words a person reads for a task state, from evidence only. */ +export function stateWords(task: TaskRecord): string { + switch (task.state) { + case 'running': return 'running'; + case 'stopping': return task.stopRequested === 'interrupt' ? 'stopping' : task.stopRequested === 'terminate' ? 'stopping (SIGTERM sent)' : 'stopping (SIGKILL sent)'; + case 'stopped': return task.leftovers ? 'stopped, processes left' : 'stopped'; + case 'exited': return task.leftovers ? 'exited, processes left' : 'exited'; + case 'failed': return task.signal ? `failed (signal ${task.signal})` : `failed (exit ${task.exitCode ?? '?'})`; + } +} + +const marks = (safe: boolean): Record => safe + ? {running: '*', stopping: '~', exited: '+', failed: 'x', stopped: '-'} + : {running: '●', stopping: '◐', exited: '✓', failed: '✗', stopped: '■'}; + +/** + * Task text as NMSh may draw it. Commands, families and directory names are not NMSh's words: a pasted command or a + * directory name can carry escape sequences, so whole sequences, controls and bidi overrides are removed before any + * of it reaches the screen or a copy. + */ +export function taskText(value: string, maxLength = 512): string { + return stripTerminalControls(value.replace(/\t/gu, ' '), maxLength).trim(); +} + +/** The first line of a task's command, displayable, and how many lines follow it. */ +export function commandLine(task: TaskRecord): {text: string; more: number} { + const lines = task.command.split('\n'); + return {text: taskText(lines[0]!), more: lines.length - 1}; +} + +/** A short label: the family when known (`adb logcat`), else the command's first line. */ +export function taskLabel(task: TaskRecord): string { + return taskText(task.family ?? commandLine(task).text, 160) || task.id; +} + +export interface BarRenderOptions { + width: number; + now: number; + safe: boolean; + color: boolean; + /** The next escalation per task, so the focused row can name what `x` does. */ + next?: (taskId: string) => StopLevel | undefined; + maxRows?: number; + /** The Up preference, so the hint names the key that actually enters the bar. */ + upArrow?: 'idle' | 'off'; +} + +/** + * Exactly `controller.rows(tasks)` lines, each at most `width` cells. State has a mark and a word, never color alone; + * the focused selection is marked with `›`/`>`, and the keys shown are the ones that work for it. + */ +export function renderTaskBar(controller: TaskBarController, tasks: readonly TaskRecord[], options: BarRenderOptions): string[] { + controller.sync(tasks); + if (!tasks.length) return []; + const width = Math.max(1, options.width); + const m = marks(options.safe); + const reset = options.color ? '\u001b[0m' : ''; + const tone = (state: TaskState) => state === 'failed' ? UI_COLORS.failure : state === 'running' ? UI_COLORS.accent : state === 'stopping' ? UI_COLORS.secondary : UI_COLORS.subtle; + const paint = (text: string, color: {red: number; green: number; blue: number}, bold = false) => options.color ? `${bold ? '\u001b[1m' : ''}${foreground(color)}${text}${reset}` : text; + const selected = tasks.find(task => task.id === controller.selectedId) ?? tasks[0]!; + const keysFor = (task: TaskRecord) => [ + 'Enter view', stopHint(task, options.next, options.now), removalBlocked(task) ? '' : 'Del remove', 'Esc back', + ].filter(Boolean); + const rowText = (task: TaskRecord, focused: boolean, suffix = '') => { + const lead = `${focused ? (options.safe ? '>' : '›') : ' '}${m[task.state]} `; + const time = elapsed((task.endedAt ?? options.now) - task.startedAt); + const label = taskLabel(task); + // Which task this is matters more than its timer: narrow rows drop the time, then the long state words, before + // the label goes below a dozen cells. + const keep = Math.min(displayWidth(label), 12); + const tails = [` ${time} ${stateWords(task)}${suffix}`, ` ${stateWords(task)}${suffix}`, ` ${shortState(task)}${suffix}`, ` ${shortState(task)}`]; + const tail = tails.find(candidate => width - displayWidth(lead) - displayWidth(candidate) >= keep) ?? tails.at(-1)!; + const room = Math.max(1, width - displayWidth(lead) - displayWidth(tail)); + return truncateText(`${lead}${truncateText(label, Math.max(1, Math.min(room, 32)))}${tail}`, width); + }; + const row = (task: TaskRecord, focused: boolean) => paint(rowText(task, focused), tone(task.state), focused); + if (!controller.expanded) { + if (controller.focused) { + const position = tasks.length > 1 ? ` ${tasks.indexOf(selected) + 1}/${tasks.length}` : ''; + const base = rowText(selected, true, position); + const room = width - displayWidth(base) - 2; + // Keys only when at least the way back fits beside the task; otherwise the task row alone. + const keys = room >= displayWidth('Esc back') ? ` ${fitHints(keysFor(selected), room)}` : ''; + return [paint(`${base}${keys}`, tone(selected.state), true)]; + } + const order: TaskState[] = ['running', 'stopping', 'failed', 'stopped', 'exited']; + const counts = order.map(state => [state, tasks.filter(task => task.state === state).length] as const).filter(([, count]) => count); + const running = tasks.filter(task => LIVE.has(task.state)).length; + const summary = `${running ? m.running : m.exited} ${tasks.length} background task${tasks.length === 1 ? '' : 's'} · ${counts.map(([state, count]) => `${count} ${state}`).join(' · ')}`; + return [paint(truncateText(summary, width), running ? UI_COLORS.secondary : UI_COLORS.subtle)]; + } + const {visible} = expandedLayout(tasks.length, options.maxRows ?? 4); + const index = Math.max(0, tasks.indexOf(selected)); + const start = Math.max(0, Math.min(index - visible + 1, tasks.length - visible)); + const up = options.safe ? '^' : '↑'; + const enter = options.upArrow === 'off' ? `Shift+${up}` : up; + const title = 'Background tasks '; + const header = !controller.focused ? `${title}${enter} or /background` + : width - displayWidth(title) >= displayWidth('Esc back') ? `${title}${fitHints(keysFor(selected), width - displayWidth(title))}` : title.trim(); + const lines = [paint(truncateText(header, width), UI_COLORS.subtle)]; + for (const task of tasks.slice(start, start + visible)) lines.push(row(task, controller.focused && task.id === selected.id)); + if (tasks.length > visible) lines.push(paint(truncateText(` +${tasks.length - visible} more · /background lists all`, width), UI_COLORS.subtle)); + return lines; +} diff --git a/src/backgroundTasks/manager.ts b/src/backgroundTasks/manager.ts new file mode 100644 index 00000000..c3b08090 --- /dev/null +++ b/src/backgroundTasks/manager.ts @@ -0,0 +1,188 @@ +import type {Key} from '../terminal/keys.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateText} from '../util/text.js'; +import {commandLine, elapsed, fitHints, removalBlocked, stateWords, stopChoice, taskLabel, taskText} from './bar.js'; +import type {TaskOutput} from './output.js'; +import {rememberFamily, type BackgroundPreferences} from './preferences.js'; +import type {StopLevel, TaskRecord} from './supervisor.js'; + +/** + * `/background` (`/bg`, #382): the full manager. Tasks tab: every task of the + * session, running first, with details on the selected one; Enter opens its + * live output, `x` stops (again: the next escalation), Delete removes a + * finished task, `c` clears all finished ones. Preferences tab: suggestions, + * task bar, Up arrow, notifications, retention and the per-family decisions. + * Changes are returned as new preferences for the caller to save; the + * manager performs no I/O. + */ + +export type ManagerOutcome = + | {kind: 'none'; message?: string} + | {kind: 'close'} + | {kind: 'view'; taskId: string} + | {kind: 'stop'; taskId: string; level: StopLevel} + | {kind: 'remove'; taskId: string} + | {kind: 'clear'} + | {kind: 'preferences'; value: BackgroundPreferences}; + +interface Setting { + label: string; + value: (p: BackgroundPreferences) => string; + change: (p: BackgroundPreferences, direction: number) => BackgroundPreferences; +} + +const cycle = (values: readonly T[], current: T, direction: number): T => { + const index = values.indexOf(current); + return values[((index === -1 ? 0 : index) + direction + values.length) % values.length]!; +}; + +const SETTINGS: readonly Setting[] = [ + {label: 'Suggestions', value: p => (p.suggestions === 'ask' ? 'ask for recognized commands' : 'off'), change: (p, d) => ({...p, suggestions: cycle(['ask', 'off'] as const, p.suggestions, d)})}, + {label: 'Task bar', value: p => p.bar, change: (p, d) => ({...p, bar: cycle(['collapsed', 'expanded'] as const, p.bar, d)})}, + {label: 'Up enters the bar', value: p => (p.upArrow === 'idle' ? 'from an idle composer' : 'off (Shift+Up still does)'), change: (p, d) => ({...p, upArrow: cycle(['idle', 'off'] as const, p.upArrow, d)})}, + {label: 'Notify when a task ends', value: p => ({never: 'never', failure: 'when it fails', always: 'always'})[p.notify], change: (p, d) => ({...p, notify: cycle(['never', 'failure', 'always'] as const, p.notify, d)})}, + {label: 'Output kept per task', value: p => `${p.retention.lines.toLocaleString('en-US')} lines`, change: (p, d) => ({...p, retention: {...p.retention, lines: cycle([1000, 5000, 20_000, 100_000], p.retention.lines, d)}})}, + {label: 'Finished tasks kept', value: p => String(p.retention.finishedKept), change: (p, d) => ({...p, retention: {...p.retention, finishedKept: cycle([5, 20, 50], p.retention.finishedKept, d)}})}, +]; + +const live = (task: TaskRecord) => task.state === 'running' || task.state === 'stopping'; + +export class BackgroundManager { + tab: 'tasks' | 'preferences' = 'tasks'; + /** The selected row of the Preferences tab. */ + selected = 0; + /** + * The selected task, by id: the order changes while the manager is open (a task finishes and moves down, a new one + * starts at the top), and a row index would then make `x` or Delete act on a task the person never chose. + */ + selectedTaskId?: string; + private taskIndex = 0; + /** First task row shown when the list is taller than the room it has. */ + private listTop = 0; + + /** Running tasks first, then finished ones, newest first. */ + static order(tasks: readonly TaskRecord[]): TaskRecord[] { + return [...tasks].sort((a, b) => Number(live(b)) - Number(live(a)) || (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt)); + } + + /** The selected task's position in `ordered`: the same task while it exists, else the one now in its place. */ + private resolve(ordered: readonly TaskRecord[]): number { + let index = ordered.findIndex(task => task.id === this.selectedTaskId); + if (index === -1) index = Math.max(0, Math.min(this.taskIndex, ordered.length - 1)); + this.selectedTaskId = ordered[index]?.id; + this.taskIndex = index; + return index; + } + + handleKey(key: Key, tasks: readonly TaskRecord[], preferences: BackgroundPreferences, next: (taskId: string) => StopLevel | undefined, now = Date.now()): ManagerOutcome { + if (key.kind === 'escape') return {kind: 'close'}; + if (key.kind === 'complete' || key.kind === 'focusPrevious') { this.tab = this.tab === 'tasks' ? 'preferences' : 'tasks'; this.selected = 0; return {kind: 'none'}; } + if (this.tab === 'tasks') { + const ordered = BackgroundManager.order(tasks); + const index = this.resolve(ordered); + const task = ordered[index]; + if (key.kind === 'up' || key.kind === 'down') { + const target = ordered[Math.max(0, Math.min(ordered.length - 1, index + (key.kind === 'up' ? -1 : 1)))]; + if (target) { this.selectedTaskId = target.id; this.taskIndex = ordered.indexOf(target); } + return {kind: 'none'}; + } + if (key.kind === 'enter') return task ? {kind: 'view', taskId: task.id} : {kind: 'none'}; + if (key.kind === 'delete' || key.kind === 'backspace') { + if (!task) return {kind: 'none'}; + const blocked = removalBlocked(task); + return blocked ? {kind: 'none', message: blocked} : {kind: 'remove', taskId: task.id}; + } + if (key.kind === 'text' && key.value === 'x' && task) { + const choice = stopChoice(task, next, now); + return 'level' in choice ? {kind: 'stop', taskId: task.id, level: choice.level} : {kind: 'none', message: choice.message}; + } + if (key.kind === 'text' && key.value === 'c') return ordered.some(item => !live(item)) ? {kind: 'clear'} : {kind: 'none', message: 'no finished tasks'}; + return {kind: 'none'}; + } + const families = Object.keys(preferences.families).sort(); + const count = SETTINGS.length + families.length; + if (key.kind === 'up' || key.kind === 'down') { this.selected = Math.max(0, Math.min(count - 1, this.selected + (key.kind === 'up' ? -1 : 1))); return {kind: 'none'}; } + if (key.kind === 'left' || key.kind === 'right' || key.kind === 'enter') { + const setting = SETTINGS[this.selected]; + if (setting) return {kind: 'preferences', value: setting.change(preferences, key.kind === 'left' ? -1 : 1)}; + const family = families[this.selected - SETTINGS.length]; + if (!family) return {kind: 'none'}; + return {kind: 'preferences', value: rememberFamily(preferences, family, preferences.families[family] === 'always' ? 'never' : 'always')}; + } + if (key.kind === 'delete' || key.kind === 'backspace') { + const family = families[this.selected - SETTINGS.length]; + if (!family) return {kind: 'none'}; + this.selected = Math.max(0, this.selected - 1); + return {kind: 'preferences', value: rememberFamily(preferences, family, undefined)}; + } + return {kind: 'none'}; + } + + /** Exactly `rows` lines of at most `width` cells; the last row lists the keys that work in this tab. */ + render(tasks: readonly TaskRecord[], outputs: (id: string) => TaskOutput | undefined, preferences: BackgroundPreferences, + options: {width: number; rows: number; now: number; color: boolean; safe: boolean}): string[] { + const width = Math.max(1, options.width); + const reset = options.color ? '\u001b[0m' : ''; + const paint = (text: string, color: {red: number; green: number; blue: number}, bold = false) => options.color ? `${bold ? '\u001b[1m' : ''}${foreground(color)}${text}${reset}` : text; + const fit = (text: string) => truncateText(text, width); + const pointer = options.safe ? '>' : '›'; + const tabs = `${this.tab === 'tasks' ? '[Tasks]' : ' Tasks '} ${this.tab === 'preferences' ? '[Preferences]' : ' Preferences '}`; + const lines = [paint(fit(`Background tasks ${tabs}`), UI_COLORS.primary, true)]; + const footer = this.tab === 'tasks' ? ['↑↓ select', 'Enter output', 'x stop', 'Del remove', 'c clear finished', 'Tab preferences', 'Esc close'] + : ['↑↓ select', '←→ change', 'Del forget a command', 'Tab tasks', 'Esc close']; + if (this.tab === 'tasks') { + const ordered = BackgroundManager.order(tasks); + if (!ordered.length) lines.push(paint(fit(' No background tasks. Run one with /background run , or choose b when NMSh suggests it.'), UI_COLORS.subtle)); + const selectedIndex = this.resolve(ordered); + const task = ordered[selectedIndex]; + const details: string[] = []; + if (task) { + const output = outputs(task.id); + const command = commandLine(task); + details.push('', paint('Details', UI_COLORS.primary, true)); + details.push(fit(` Command ${command.text}${command.more ? ` (+${command.more} more line${command.more === 1 ? '' : 's'})` : ''}`)); + details.push(fit(` In ${taskText(task.cwd)}`), fit(` Started ${new Date(task.startedAt).toLocaleTimeString('en-GB')}`)); + if (task.endedAt !== undefined) details.push(fit(` Ended ${new Date(task.endedAt).toLocaleTimeString('en-GB')} · ${task.signal ? `signal ${task.signal}` : `exit ${task.exitCode}`}`)); + if (task.leftovers) details.push(paint(fit(' Left processes of this task still run; x kills them'), UI_COLORS.failure)); + if (output) details.push(fit(` Output ${output.lines().length} lines kept${output.dropped ? `, ${output.dropped} dropped by the retention limit` : ''}`)); + details.push(paint(fit(" Runs in its own terminal, without this shell's aliases, functions or unexported variables."), UI_COLORS.subtle)); + } + // The list keeps at least three rows (or all tasks, when fewer) and gives the rest to the details; a longer + // list scrolls with the selection and says how many rows it does not show. + const room = Math.max(1, options.rows - 2); + const listRows = Math.max(Math.min(ordered.length, 3), room - details.length); + const windowed = ordered.length > listRows; + const visible = windowed ? Math.max(1, listRows - 1) : ordered.length; + if (selectedIndex < this.listTop) this.listTop = selectedIndex; + if (selectedIndex >= this.listTop + visible) this.listTop = selectedIndex - visible + 1; + this.listTop = Math.max(0, Math.min(this.listTop, ordered.length - visible)); + ordered.slice(this.listTop, this.listTop + visible).forEach(item => { + const chosen = item.id === task?.id; + const text = `${chosen ? pointer : ' '} ${item.id.padEnd(4)} ${taskLabel(item)} ${elapsed((item.endedAt ?? options.now) - item.startedAt)} ${stateWords(item)}`; + lines.push(paint(fit(text), item.state === 'failed' ? UI_COLORS.failure : chosen ? UI_COLORS.accent : UI_COLORS.secondary, chosen)); + }); + if (windowed) { + const above = this.listTop, below = ordered.length - this.listTop - visible; + lines.push(paint(fit(` ${[above ? `${above} above` : '', below ? `${below} below` : ''].filter(Boolean).join(' · ')}`), UI_COLORS.subtle)); + } + lines.push(...details); + } else { + SETTINGS.forEach((setting, index) => { + const chosen = index === this.selected; + lines.push(paint(fit(`${chosen ? pointer : ' '} ${setting.label.padEnd(26)} ${setting.value(preferences)}`), chosen ? UI_COLORS.accent : UI_COLORS.secondary, chosen)); + }); + lines.push('', paint('Remembered commands', UI_COLORS.primary, true)); + const families = Object.keys(preferences.families).sort(); + if (!families.length) lines.push(paint(fit(' None yet: choose a or n when NMSh suggests a command.'), UI_COLORS.subtle)); + families.forEach((family, index) => { + const chosen = SETTINGS.length + index === this.selected; + const word = preferences.families[family] === 'always' ? 'always in the background' : 'never ask'; + lines.push(paint(fit(`${chosen ? pointer : ' '} ${taskText(family, 64).padEnd(26)} ${word}`), chosen ? UI_COLORS.accent : UI_COLORS.secondary, chosen)); + }); + } + const body = lines.slice(0, Math.max(0, options.rows - 1)); + while (body.length < options.rows - 1) body.push(''); + if (options.rows >= 1) body.push(paint(fitHints(footer, width), UI_COLORS.subtle)); + return body.slice(0, Math.max(0, options.rows)); + } +} diff --git a/src/backgroundTasks/suggestion.ts b/src/backgroundTasks/suggestion.ts new file mode 100644 index 00000000..cc6dd1ee --- /dev/null +++ b/src/backgroundTasks/suggestion.ts @@ -0,0 +1,98 @@ +import type {Key} from '../terminal/keys.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateText} from '../util/text.js'; +import {classifyCommand, isClassified, type Classification} from './detector.js'; + +/** + * The background suggestion (#382): a strip above the composer, shown when a + * submitted command belongs to a recognized family and the person has not + * decided for that family. It is not a modal: Enter runs the command in the + * foreground exactly as it would have run (so pressing Enter twice is the + * old behavior), and the strip says what running in the background changes. + * + * b background once Enter run here (foreground) + * a always background n don't ask for this family + * Esc back to the draft, unchanged + */ + +export type SuggestionChoice = 'background' | 'foreground' | 'always' | 'never' | 'edit'; + +export interface Suggestion { + command: string; + classification: Classification; +} + +export function suggestionKey(key: Key): SuggestionChoice | undefined { + if (key.kind === 'enter') return 'foreground'; + if (key.kind === 'escape') return 'edit'; + if (key.kind !== 'text') return undefined; + return ({b: 'background', a: 'always', n: 'never'} as const)[key.value as 'b' | 'a' | 'n']; +} + +/** One or two rows: what the command does and the choices; the second row names what a background run does not inherit. */ +export function renderSuggestion(suggestion: Suggestion, options: {width: number; color: boolean; safe: boolean}): string[] { + const width = Math.max(1, options.width); + const reset = options.color ? '\u001b[0m' : ''; + const paint = (text: string, color: {red: number; green: number; blue: number}, bold = false) => options.color ? `${bold ? '\u001b[1m' : ''}${foreground(color)}${text}${reset}` : text; + const mark = options.safe ? '~' : '↻'; + const likely = suggestion.classification.likelihood === 'likely' ? ' (it may keep running)' : ''; + const choices = 'b background · Enter run here · a always · n don\'t ask · Esc edit'; + const first = `${mark} ${suggestion.classification.reason}${likely}`; + if (width < 48) return [paint(truncateText(`${mark} ${suggestion.classification.family}: b bg · Enter here`, width), UI_COLORS.accent, true)]; + return [ + paint(truncateText(first, width), UI_COLORS.accent, true), + paint(truncateText(`${choices} — in the background it runs separately, without this shell's aliases, functions or unexported variables`, width), UI_COLORS.subtle), + ]; +} + +export type ExplicitRun = {ok: true; command: string; classification?: Classification; notes: string[]} | {ok: false; reason: string}; + +/** + * `/background run `: the person chose background execution explicitly. The command text after `run ` is kept + * byte for byte. Programs that need the terminal or the person's input are refused (they would wait forever on a + * task's terminal nobody sees); everything else runs, with notes when it depends on things a separate shell does not + * have. + */ +export function explicitRun(input: string): ExplicitRun { + const match = /^\/(?:background|bg)[ \t]+run[ \t]+([\s\S]+)$/u.exec(input); + if (!match) return {ok: false, reason: 'usage: /background run '}; + const command = match[1]!.replace(/^[ \t]+/u, ''); + const result = classifyCommand(command); + if (!isClassified(result) && /needs the terminal|NMSh command/u.test(result.why)) return {ok: false, reason: `not run in the background: ${result.why}`}; + const notes: string[] = []; + if (!isClassified(result) && /expansion|substitution/u.test(result.why)) notes.push('expansions run in a separate shell: values set only in this shell are not seen'); + if (!isClassified(result) && /several lines|command list|background job|subshell/u.test(result.why)) notes.push('runs as one script in a separate shell'); + return {ok: true, command, ...(isClassified(result) ? {classification: result} : {}), notes}; +} + +export type BackgroundCommand = + | {kind: 'open'} + | {kind: 'run'; input: string} + | {kind: 'stop'; taskId: string; force: boolean} + | {kind: 'remove'; taskId: string} + | {kind: 'clear'} + | {kind: 'suggestions'; value: 'ask' | 'off'} + | {kind: 'forget'; family: string} + | {kind: 'invalid'; reason: string}; + +/** `/background` and its short form `/bg`. Task ids are NMSh's own (`t3`). */ +export function parseBackgroundCommand(input: string): BackgroundCommand | undefined { + // trimEnd, not `/\s+$/`: the regular expression retries from every space of a long run (a paste), quadratically. + const match = /^\/(background|bg)(?:[ \t]+([\s\S]*))?$/u.exec(input.trimEnd()); + if (!match) return undefined; + const rest = (match[2] ?? '').trim(); + if (!rest) return {kind: 'open'}; + const [verb, ...args] = rest.split(/[ \t]+/u); + switch (verb) { + case 'run': return {kind: 'run', input}; + case 'stop': case 'kill': { + const taskId = args[0]; + return taskId && /^t\d{1,6}$/u.test(taskId) ? {kind: 'stop', taskId, force: verb === 'kill'} : {kind: 'invalid', reason: `usage: /background ${verb} t`}; + } + case 'remove': return args[0] && /^t\d{1,6}$/u.test(args[0]) ? {kind: 'remove', taskId: args[0]} : {kind: 'invalid', reason: 'usage: /background remove t'}; + case 'clear': return {kind: 'clear'}; + case 'suggestions': return args[0] === 'on' || args[0] === 'off' ? {kind: 'suggestions', value: args[0] === 'on' ? 'ask' : 'off'} : {kind: 'invalid', reason: 'usage: /background suggestions on|off'}; + case 'forget': return args.length ? {kind: 'forget', family: args.join(' ')} : {kind: 'invalid', reason: 'usage: /background forget '}; + default: return {kind: 'invalid', reason: `unknown: ${verb!.slice(0, 32)}; try /background run, stop, remove, clear, suggestions, forget`}; + } +} diff --git a/src/backgroundTasks/view.ts b/src/backgroundTasks/view.ts new file mode 100644 index 00000000..dcb7013b --- /dev/null +++ b/src/backgroundTasks/view.ts @@ -0,0 +1,124 @@ +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateText} from '../util/text.js'; +import {commandLine, elapsed, fitHints, stateWords, taskLabel, taskText} from './bar.js'; +import type {OutputLine, TaskOutput} from './output.js'; +import type {TaskRecord} from './supervisor.js'; + +/** + * How a background task's output appears (#382). Two pure presentations over + * the same bounded stream: + * + * - a **transcript block**, NMSh-owned and labelled with the task, placed in + * the main transcript where the task started; it shows the latest lines + * (folded) and is never mixed into a foreground command's block, so + * `git status` run meanwhile keeps its own block; + * - a **focused live view** (Enter on a task), which follows new output + * unless the person scrolls back, and then says how many lines are new + * instead of jumping. A scrolled-back view is anchored to the line it + * shows, not to a row index, so lines dropped by the retention limit at + * the top do not move what the person is reading. + * + * Lines are already sanitized by TaskOutput; nothing here interprets them. + */ + +export interface BlockOptions { + width: number; + now: number; + /** Latest lines shown in the folded block. */ + lines?: number; + color: boolean; + safe: boolean; +} + +/** The task's transcript block: a header naming the task, its latest lines, and what was folded or dropped. */ +export function taskBlock(task: TaskRecord, output: TaskOutput, options: BlockOptions): string[] { + const width = Math.max(1, options.width); + const reset = options.color ? '\u001b[0m' : ''; + const paint = (text: string, color: {red: number; green: number; blue: number}) => options.color ? `${foreground(color)}${text}${reset}` : text; + const time = elapsed((task.endedAt ?? options.now) - task.startedAt); + const header = `${options.safe ? '>' : '▸'} Background · ${taskLabel(task)} · ${task.id} · ${stateWords(task)} ${time}`; + const lines = output.lines(); + const shown = lines.slice(-(options.lines ?? 6)); + const earlier = lines.length - shown.length + output.dropped; + const result = [paint(truncateText(header, width), UI_COLORS.secondary)]; + if (earlier > 0) result.push(paint(truncateText(` … ${earlier.toLocaleString('en-US')} earlier line${earlier === 1 ? '' : 's'}${output.dropped ? ` (${output.dropped.toLocaleString('en-US')} beyond the retention limit)` : ''} · /background ${task.id}`, width), UI_COLORS.subtle)); + for (const line of shown) result.push(truncateText(` ${line.text}`, width)); + const partial = output.partial(); + if (partial && (task.state === 'running' || task.state === 'stopping')) result.push(truncateText(` ${partial}`, width)); + if (!lines.length && !partial) result.push(paint(' (no output yet)', UI_COLORS.subtle)); + return result; +} + +/** Plain text of a block for copying: the task's own lines with its label, never NMSh's styling. */ +export function taskBlockText(task: TaskRecord, output: TaskOutput): string { + return [`# background ${task.id}: ${commandLine(task).text}`, ...output.lines().map(line => line.text)].join('\n'); +} + +/** Index of the first kept line at or after `seq`. Kept lines have consecutive seqs, so this is arithmetic. */ +function indexOfSeq(lines: readonly OutputLine[], seq: number): number { + if (!lines.length) return 0; + return Math.max(0, Math.min(lines.length - 1, seq - lines[0]!.seq)); +} + +/** A scrolled-back view's status: its notes while they fit, last note first to go; End and Esc always. */ +function scrolledStatus(notes: string[], width: number): string { + const keys = ['End follow', 'Esc back']; + const kept = notes.filter(Boolean); + while (kept.length && displayWidth([...kept, ...keys].join(' · ')) > width) kept.pop(); + return fitHints([...kept, ...keys], width); +} + +export class TaskOutputView { + /** Whether new lines keep the view at the bottom. Scrolling back turns it off; End turns it on again. */ + follow = true; + /** The seq of the first line shown, when not following: what the person is reading stays put as lines arrive or go. */ + topSeq = 0; + /** The last line seq the person has seen at the bottom; lines after it are "new". */ + seenSeq = 0; + /** The line being read was dropped by the retention limit; the view moved to the oldest line still kept. */ + private overtaken = false; + + scroll(delta: number, output: TaskOutput, height: number): void { + const lines = output.lines(); + const bottom = Math.max(0, lines.length - height); + const current = this.follow ? bottom : indexOfSeq(lines, this.topSeq); + const next = Math.max(0, Math.min(bottom, current + delta)); + this.follow = next >= bottom; + this.topSeq = lines[next]?.seq ?? 0; + this.overtaken = false; + if (this.follow) this.seenSeq = output.lastSeq; + } + + end(output: TaskOutput): void { this.follow = true; this.overtaken = false; this.seenSeq = output.lastSeq; } + + /** Exactly `height` lines: header, output window, and a status row saying what is new or how to follow again. */ + render(task: TaskRecord, output: TaskOutput, options: {width: number; height: number; now: number; color: boolean; safe: boolean}): string[] { + const width = Math.max(1, options.width); + const body = Math.max(0, options.height - 2); + const lines = output.lines(); + // While following a live task, its unfinished line (a progress bar, a prompt without a newline) is the last row. + const partial = this.follow && (task.state === 'running' || task.state === 'stopping') ? output.partial() : ''; + let top: number; + if (this.follow) { + top = Math.max(0, lines.length - Math.max(0, body - (partial ? 1 : 0))); + this.seenSeq = output.lastSeq; + } else { + if (lines.length && this.topSeq < lines[0]!.seq) { this.topSeq = lines[0]!.seq; this.overtaken = true; } + top = Math.min(indexOfSeq(lines, this.topSeq), Math.max(0, lines.length - body)); + } + const reset = options.color ? '\u001b[0m' : ''; + const paint = (text: string, color: {red: number; green: number; blue: number}) => options.color ? `${foreground(color)}${text}${reset}` : text; + const header = `${taskLabel(task)} · ${task.id} · ${stateWords(task)} ${elapsed((task.endedAt ?? options.now) - task.startedAt)} · ${taskText(task.cwd)}`; + const out = [paint(truncateText(header, width), UI_COLORS.secondary)]; + for (const line of lines.slice(top, top + body)) out.push(truncateText(line.text, width)); + if (partial && out.length - 1 < body) out.push(truncateText(partial, width)); + while (out.length < options.height - 1) out.push(''); + const fresh = Math.min(lines.length, Math.max(0, output.lastSeq - this.seenSeq)); + const live = task.state === 'running' || task.state === 'stopping'; + const status = this.follow + ? fitHints([`following · ${lines.length} lines${output.dropped ? ` (+${output.dropped} dropped)` : ''}`, live ? 'x stop' : '', 'Esc back'], width) + : scrolledStatus([fresh ? `${fresh} new line${fresh === 1 ? '' : 's'} below` : '', this.overtaken ? 'older lines dropped (retention limit)' : ''], width); + if (options.height >= 2) out.push(paint(status, UI_COLORS.subtle)); + return out.slice(0, Math.max(0, options.height)); + } +} diff --git a/tests/backgroundTaskUi.test.ts b/tests/backgroundTaskUi.test.ts new file mode 100644 index 00000000..6e6ddd32 --- /dev/null +++ b/tests/backgroundTaskUi.test.ts @@ -0,0 +1,322 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {entersBar, ESCALATION_PAUSE_MS, fitHints, renderTaskBar, TaskBarController, taskLabel, type ComposerState} from '../src/backgroundTasks/bar.js'; +import {explicitRun, parseBackgroundCommand, renderSuggestion, suggestionKey} from '../src/backgroundTasks/suggestion.js'; +import {taskBlock, taskBlockText, TaskOutputView} from '../src/backgroundTasks/view.js'; +import {BackgroundManager} from '../src/backgroundTasks/manager.js'; +import {TaskOutput} from '../src/backgroundTasks/output.js'; +import {DEFAULT_PREFERENCES, rememberFamily} from '../src/backgroundTasks/preferences.js'; +import {classifyCommand} from '../src/backgroundTasks/detector.js'; +import type {StopLevel, TaskRecord} from '../src/backgroundTasks/supervisor.js'; +import type {Key} from '../src/terminal/keys.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const k = (kind: Key['kind'], value?: string): Key => (value === undefined ? {kind} : {kind, value}) as Key; +const NOW = 1_000_000; +const task = (id: string, extra: Partial = {}): TaskRecord => ({id, command: 'adb logcat', family: 'adb logcat', cwd: '/w', shell: '/bin/zsh', startedAt: NOW - 261_000, state: 'running', ...extra}); +const TASKS: TaskRecord[] = [task('t1'), task('t2', {command: 'scrcpy', family: 'scrcpy', startedAt: NOW - 158_000}), + task('t3', {command: 'npm run dev', family: 'npm run dev', state: 'failed', exitCode: 1, endedAt: NOW - 1000, startedAt: NOW - 662_000})]; +const idle: ComposerState = {empty: true, browsingHistory: false, foregroundBusy: false, otherOwner: false}; +const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‮]/u; + +test('entering the bar: Up only from an idle composer; Shift+Up always from an empty one; never while input is owned', () => { + assert.equal(entersBar(k('up'), idle, 2, 'idle'), true); + assert.equal(entersBar(k('up'), {...idle, empty: false}, 2, 'idle'), false, 'a draft keeps Up for its own lines and history'); + assert.equal(entersBar(k('up'), {...idle, browsingHistory: true}, 2, 'idle'), false, 'history browsing continues'); + assert.equal(entersBar(k('up'), {...idle, foregroundBusy: true}, 2, 'idle'), false, 'a running or waiting foreground command owns the keys'); + assert.equal(entersBar(k('selectUp'), {...idle, foregroundBusy: true}, 2, 'idle'), false); + assert.equal(entersBar(k('up'), {...idle, otherOwner: true}, 2, 'idle'), false); + assert.equal(entersBar(k('up'), idle, 0, 'idle'), false, 'nothing to enter'); + assert.equal(entersBar(k('up'), idle, 2, 'off'), false, 'the preference turns Up off'); + assert.equal(entersBar(k('selectUp'), idle, 2, 'off'), true, 'Shift+Up still works'); + assert.equal(entersBar(k('down'), idle, 2, 'idle'), false); +}); + +test('bar keys: select, view, stop then escalate, remove only finished tasks, typing returns to the composer with the key', () => { + const bar = new TaskBarController(); + bar.focused = true; + const next = (levels: Record) => (id: string) => levels[id]; + assert.equal(bar.handleKey(k('enter'), TASKS, next({})).kind, 'view'); + assert.deepEqual(bar.handleKey(k('text', 'x'), TASKS, next({t1: 'interrupt'})), {kind: 'stop', taskId: 't1', level: 'interrupt'}); + assert.deepEqual(bar.handleKey(k('text', 'x'), [{...TASKS[0]!, state: 'stopping', stopRequested: 'interrupt'}], next({t1: 'terminate'})), {kind: 'stop', taskId: 't1', level: 'terminate'}); + assert.equal(bar.handleKey(k('text', 'x'), [{...TASKS[0]!, state: 'stopping', stopRequested: 'kill'}], next({})).kind, 'none'); + assert.deepEqual(bar.handleKey(k('delete'), TASKS, next({})), {kind: 'none', message: 'stop it first (x); a running task is never removed'}); + bar.handleKey(k('down'), TASKS, next({})); bar.handleKey(k('down'), TASKS, next({})); + assert.equal(bar.selectedId, 't3'); + assert.equal(bar.handleKey(k('text', 'x'), TASKS, next({})).kind, 'none', 'x on a finished task stops nothing'); + assert.deepEqual(bar.handleKey(k('delete'), TASKS, next({})), {kind: 'remove', taskId: 't3'}); + assert.deepEqual(bar.handleKey(k('down'), TASKS, next({})), {kind: 'leave'}, 'down past the last task returns to the composer below'); + bar.focused = true; + assert.equal(bar.handleKey(k('text', ' '), TASKS, next({})).kind, 'toggle'); + assert.equal(bar.expanded, true); + assert.deepEqual(bar.handleKey(k('text', 'g'), TASKS, next({})), {kind: 'leave', forward: {kind: 'text', value: 'g'}}, 'typing is not lost'); + assert.equal(bar.focused, false); + bar.focused = true; + assert.deepEqual(bar.handleKey(k('escape'), TASKS, next({})), {kind: 'leave'}); + bar.sync([]); + assert.equal(bar.focused, false, 'no tasks, no focus'); +}); + +test('bar rendering: no rows when empty; one summary row collapsed; bounded rows expanded; exact width; marks without color', () => { + const bar = new TaskBarController(); + assert.equal(bar.rows([]), 0); + assert.deepEqual(renderTaskBar(bar, [], {width: 80, now: NOW, safe: true, color: false}), []); + const summary = renderTaskBar(bar, TASKS, {width: 80, now: NOW, safe: true, color: false}); + assert.deepEqual(summary, ['* 3 background tasks · 2 running · 1 failed']); + bar.focused = true; + bar.selectedId = 't1'; + assert.match(renderTaskBar(bar, TASKS, {width: 120, now: NOW, safe: true, color: false, next: () => 'interrupt'})[0]!, /^>\* adb logcat {2}04:21 {2}running {2}1\/3 {2}Enter view · x stop · Esc back$/u); + bar.expanded = true; + const many = Array.from({length: 9}, (_, i) => task(`t${i + 1}`, {family: undefined, command: `tail -f /var/log/app${i}.log`})); + for (const tasks of [TASKS, many]) { + for (const width of [10, 24, 40, 80, 160]) for (const safe of [true, false]) for (const color of [true, false]) { + const lines = renderTaskBar(bar, tasks, {width, now: NOW, safe, color, next: () => 'interrupt'}); + assert.equal(lines.length, bar.rows(tasks), `${width}: rows reserved = rows drawn`); + assert.ok(lines.length <= 4); + for (const line of lines) assert.ok(displayWidth(stripAnsi(line)) <= width, `${width}: ${stripAnsi(line)}`); + } + } + const expanded = renderTaskBar(bar, many, {width: 80, now: NOW, safe: true, color: false}); + assert.match(expanded.at(-1)!, /\+\d+ more · \/background lists all/u); + const states: TaskRecord[] = (['running', 'stopping', 'exited', 'failed', 'stopped'] as const).map((state, i) => task(`t${i}`, {state, endedAt: state === 'running' || state === 'stopping' ? undefined : NOW})); + for (const safe of [true, false]) { + bar.expanded = true; bar.focused = false; + const rows = renderTaskBar(bar, states, {width: 80, now: NOW, safe, color: false, maxRows: 10}).slice(1); + assert.equal(new Set(rows.map(row => row.trimStart()[0])).size, 5, 'each state has its own mark'); + assert.ok(rows.every(row => /running|stopping|exited|failed|stopped/u.test(row)), 'and its own word'); + } +}); + +test('hostile commands and directories are drawn as text only', () => { + const evil = task('t9', {family: undefined, command: '\u001b]0;PWNED\u0007\u001b[2Jtail -f ‮evil.log\nrm -rf ~', cwd: '/tmp/\u001b[31mred\u0007dir'}); + assert.equal(taskLabel(evil), 'tail -f evil.log'); + const output = new TaskOutput({lines: 50, bytes: 10_000}); + const bar = new TaskBarController(true); + const manager = new BackgroundManager(); + const view = new TaskOutputView(); + const drawn = [ + ...renderTaskBar(bar, [evil], {width: 80, now: NOW, safe: false, color: true}), + ...manager.render([evil], () => output, DEFAULT_PREFERENCES(), {width: 100, rows: 20, now: NOW, color: true, safe: false}), + ...view.render(evil, output, {width: 100, height: 6, now: NOW, color: true, safe: false}), + ...taskBlock(evil, output, {width: 100, now: NOW, color: true, safe: false}), taskBlockText(evil, output), + ]; + for (const line of drawn) assert.doesNotMatch(stripAnsi(line), CONTROL, JSON.stringify(stripAnsi(line))); + assert.ok(drawn.some(line => stripAnsi(line).includes('/tmp/reddir'))); + assert.ok(drawn.some(line => stripAnsi(line).includes('(+1 more line)')), 'a hidden second line is announced, not shown as if absent'); +}); + +test('suggestion strip: Enter keeps the foreground path; choices are single keys; the strip says what background changes', () => { + assert.equal(suggestionKey(k('enter')), 'foreground'); + assert.equal(suggestionKey(k('escape')), 'edit'); + assert.equal(suggestionKey(k('text', 'b')), 'background'); + assert.equal(suggestionKey(k('text', 'a')), 'always'); + assert.equal(suggestionKey(k('text', 'n')), 'never'); + assert.equal(suggestionKey(k('text', 'q')), undefined); + const classification = classifyCommand('adb logcat') as Exclude, {why: string}>; + const wide = renderSuggestion({command: 'adb logcat', classification}, {width: 200, color: false, safe: true}); + assert.equal(wide.length, 2); + assert.match(wide[0]!, /adb logcat keeps streaming device logs/u); + assert.match(wide[1]!, /without this shell's aliases, functions or unexported variables/u); + for (const width of [12, 30, 47, 48, 80]) for (const line of renderSuggestion({command: 'adb logcat', classification}, {width, color: true, safe: false})) assert.ok(displayWidth(stripAnsi(line)) <= width); + const likely = classifyCommand('npm run dev') as Exclude, {why: string}>; + assert.match(renderSuggestion({command: 'npm run dev', classification: likely}, {width: 200, color: false, safe: true})[0]!, /it may keep running/u); +}); + +test('/background: explicit runs keep the command byte for byte; interactive programs are refused; subcommands parse', () => { + assert.deepEqual(explicitRun(`/background run printf '%s\\n' "a b"`), {ok: true, command: `printf '%s\\n' "a b"`, notes: []}); + const logcat = explicitRun('/bg run adb logcat | grep -i Activity'); + assert.ok(logcat.ok && logcat.classification?.family === 'adb logcat'); + assert.deepEqual(explicitRun('/background run sudo tail -f /var/log/x'), {ok: false, reason: 'not run in the background: sudo needs the terminal or the shell itself'}); + assert.equal(explicitRun('/background run vim notes.md').ok, false); + const expanding = explicitRun('/background run tail -f "$LOG"'); + assert.ok(expanding.ok && expanding.notes[0]!.includes('values set only in this shell are not seen')); + assert.deepEqual(explicitRun('/background run'), {ok: false, reason: 'usage: /background run '}); + assert.deepEqual(parseBackgroundCommand('/background'), {kind: 'open'}); + assert.deepEqual(parseBackgroundCommand('/bg '), {kind: 'open'}); + assert.deepEqual(parseBackgroundCommand('/bg stop t3'), {kind: 'stop', taskId: 't3', force: false}); + assert.deepEqual(parseBackgroundCommand('/bg kill t3'), {kind: 'stop', taskId: 't3', force: true}); + assert.deepEqual(parseBackgroundCommand('/background stop 3'), {kind: 'invalid', reason: 'usage: /background stop t'}); + assert.deepEqual(parseBackgroundCommand('/background suggestions off'), {kind: 'suggestions', value: 'off'}); + assert.deepEqual(parseBackgroundCommand('/background forget adb logcat'), {kind: 'forget', family: 'adb logcat'}); + assert.equal(parseBackgroundCommand('/backgrounds'), undefined, 'not this command'); + assert.equal(parseBackgroundCommand('/background frob')?.kind, 'invalid'); +}); + +test('output view: follows new output, stops following when scrolled back and counts what is new; End follows again', () => { + const output = new TaskOutput({lines: 1000, bytes: 1_000_000}); + for (let i = 1; i <= 30; i++) output.append(`line ${i}\r\n`); + const view = new TaskOutputView(); + const running = task('t1'); + let lines = view.render(running, output, {width: 60, height: 7, now: NOW, color: false, safe: true}); + assert.equal(lines.length, 7); + assert.deepEqual(lines.slice(1, 6), ['line 26', 'line 27', 'line 28', 'line 29', 'line 30']); + assert.match(lines[6]!, /^following · 30 lines/u); + view.scroll(-10, output, 5); + for (let i = 31; i <= 34; i++) output.append(`line ${i}\r\n`); + lines = view.render(running, output, {width: 60, height: 7, now: NOW, color: false, safe: true}); + assert.equal(lines[1], 'line 16', 'the view stays where the person scrolled'); + assert.match(lines[6]!, /^4 new lines below · End follow/u); + view.end(output); + lines = view.render(running, output, {width: 60, height: 7, now: NOW, color: false, safe: true}); + assert.equal(lines[5], 'line 34'); +}); + +test('transcript block: labelled with its task, latest lines, folded and dropped counts, plain copy', () => { + const output = new TaskOutput({lines: 5, bytes: 1_000_000}); + for (let i = 1; i <= 12; i++) output.append(`log ${i}\r\n`); + output.append('partial'); + const block = taskBlock(task('t4'), output, {width: 100, now: NOW, lines: 3, color: false, safe: true}); + assert.deepEqual(block, ['> Background · adb logcat · t4 · running 04:21', ' … 9 earlier lines (7 beyond the retention limit) · /background t4', ' log 10', ' log 11', ' log 12', ' partial']); + assert.equal(taskBlockText(task('t4'), output).split('\n')[0], '# background t4: adb logcat'); + assert.deepEqual(taskBlock(task('t5'), new TaskOutput({lines: 5, bytes: 100}), {width: 80, now: NOW, color: false, safe: true}).at(-1), ' (no output yet)'); +}); + +test('manager: running first, stop/remove/clear rules, preferences changed and forgotten, exact size', () => { + const manager = new BackgroundManager(); + assert.deepEqual(BackgroundManager.order(TASKS).map(item => item.id), ['t2', 't1', 't3'], 'running first, newest first'); + const prefs = rememberFamily(DEFAULT_PREFERENCES(), 'adb logcat', 'always'); + const next = () => 'interrupt' as const; + assert.deepEqual(manager.handleKey(k('text', 'x'), TASKS, prefs, next), {kind: 'stop', taskId: 't2', level: 'interrupt'}); + assert.equal(manager.handleKey(k('delete'), TASKS, prefs, next).kind, 'none'); + manager.handleKey(k('down'), TASKS, prefs, next); manager.handleKey(k('down'), TASKS, prefs, next); + assert.deepEqual(manager.handleKey(k('delete'), TASKS, prefs, next), {kind: 'remove', taskId: 't3'}); + assert.deepEqual(manager.handleKey(k('text', 'c'), TASKS, prefs, next), {kind: 'clear'}); + assert.deepEqual(manager.handleKey(k('text', 'c'), TASKS.slice(0, 2), prefs, next), {kind: 'none', message: 'no finished tasks'}); + manager.handleKey(k('complete'), TASKS, prefs, next); + assert.equal(manager.tab, 'preferences'); + const off = manager.handleKey(k('right'), TASKS, prefs, next); + assert.equal(off.kind === 'preferences' && off.value.suggestions, 'off'); + for (let i = 0; i < 6; i++) manager.handleKey(k('down'), TASKS, prefs, next); + const flipped = manager.handleKey(k('enter'), TASKS, prefs, next); + assert.equal(flipped.kind === 'preferences' && flipped.value.families['adb logcat'], 'never'); + const forgotten = manager.handleKey(k('delete'), TASKS, prefs, next); + assert.deepEqual(forgotten.kind === 'preferences' && forgotten.value.families, {}); + for (const tab of ['tasks', 'preferences'] as const) { + manager.tab = tab; + for (const [width, rows] of [[40, 8], [100, 20], [20, 3]] as const) { + const lines = manager.render(TASKS, () => new TaskOutput({lines: 5, bytes: 100}), prefs, {width, rows, now: NOW, color: true, safe: false}); + assert.equal(lines.length, rows); + for (const line of lines) assert.ok(displayWidth(stripAnsi(line)) <= width); + } + } + assert.match(new BackgroundManager().render([], () => undefined, prefs, {width: 120, rows: 4, now: NOW, color: false, safe: true})[1]!, /No background tasks/u); +}); + +test('the manager acts on the task the person selected, even when the order changes under it', () => { + const manager = new BackgroundManager(); + const prefs = DEFAULT_PREFERENCES(); + const next = () => 'interrupt' as const; + const a = task('t1', {startedAt: NOW - 50_000}); + const b = task('t2', {startedAt: NOW - 40_000, command: 'scrcpy', family: 'scrcpy'}); + manager.handleKey(k('down'), [a, b], prefs, next); + assert.deepEqual(manager.handleKey(k('text', 'x'), [a, b], prefs, next), {kind: 'stop', taskId: 't1', level: 'interrupt'}, 'newest first: t2, then t1'); + // A new task starts at the top while t1 is selected: the selection stays on t1. + const c = task('t3', {startedAt: NOW - 1000, command: 'tail -f x', family: 'tail -f'}); + assert.deepEqual(manager.handleKey(k('text', 'x'), [a, b, c], prefs, next), {kind: 'stop', taskId: 't1', level: 'interrupt'}); + // Finished tasks: one ends and is listed above the selected finished task; Delete still removes the selected one. + const old = task('t4', {state: 'exited', exitCode: 0, endedAt: NOW - 9000}); + const ended = task('t5', {state: 'exited', exitCode: 0, endedAt: NOW - 8000}); + for (let i = 0; i < 4; i++) manager.handleKey(k('down'), [a, old], prefs, next); + assert.deepEqual(manager.handleKey(k('delete'), [a, old], prefs, next), {kind: 'remove', taskId: 't4'}); + assert.deepEqual(manager.handleKey(k('delete'), [a, old, ended], prefs, next), {kind: 'remove', taskId: 't4'}); + // The selected task removed: its neighbour is selected, not the first row. + assert.deepEqual(manager.handleKey(k('delete'), [a, ended], prefs, next), {kind: 'remove', taskId: 't5'}); +}); + +test('the manager list scrolls with the selection and leaves room for the details at any height', () => { + const manager = new BackgroundManager(); + const prefs = DEFAULT_PREFERENCES(); + const many = Array.from({length: 30}, (_, i) => task(`t${i + 1}`, {family: undefined, command: `tail -f /var/log/app${i + 1}.log`, startedAt: NOW - (i + 1) * 1000})); + for (let i = 0; i < 20; i++) manager.handleKey(k('down'), many, prefs, () => 'interrupt'); + for (const rows of [8, 14, 24]) { + const lines = manager.render(many, () => undefined, prefs, {width: 80, rows, now: NOW, color: false, safe: true}).map(stripAnsi); + assert.equal(lines.length, rows); + assert.ok(lines.some(line => /^> t21 /u.test(line)), `${rows} rows: the selected task is visible\n${lines.join('\n')}`); + assert.match(lines.find(line => /above|below/u.test(line)) ?? '', /^ {2}\d+ above( · \d+ below)?$/u, 'the rows not shown are counted'); + if (rows >= 14) assert.ok(lines.some(line => line.startsWith(' Command tail -f /var/log/app21.log')), `${rows} rows: details of the selection`); + } +}); + +test('stopping is paced: a repeated or held x cannot go from interrupt to SIGKILL at once', () => { + const bar = new TaskBarController(); + bar.focused = true; + const stopping = task('t1', {state: 'stopping', stopRequested: 'interrupt', stopRequestedAt: NOW}); + const next = () => 'terminate' as const; + const early = bar.handleKey(k('text', 'x'), [stopping], next, NOW + 200); + assert.equal(early.kind, 'none'); + assert.match((early as {message: string}).message, /give it a moment, then x sends terminate \(SIGTERM\)/u); + assert.deepEqual(bar.handleKey(k('text', 'x'), [stopping], next, NOW + ESCALATION_PAUSE_MS), {kind: 'stop', taskId: 't1', level: 'terminate'}); + const manager = new BackgroundManager(); + assert.equal(manager.handleKey(k('text', 'x'), [stopping], DEFAULT_PREFERENCES(), next, NOW + 200).kind, 'none', 'the manager uses the same rule'); + // While paused the bar does not offer the escalation; afterwards it names it. + const paused = stripAnsi(renderTaskBar(bar, [stopping], {width: 120, now: NOW + 200, safe: true, color: false, next})[0]!); + assert.doesNotMatch(paused, /x terminate/u); + assert.match(stripAnsi(renderTaskBar(bar, [stopping], {width: 120, now: NOW + ESCALATION_PAUSE_MS, safe: true, color: false, next})[0]!), /x terminate \(SIGTERM\)/u); +}); + +test('a finished task with processes left: x kills what is left, Delete waits until nothing runs', () => { + const bar = new TaskBarController(); + bar.focused = true; + const left = task('t1', {state: 'exited', exitCode: 0, endedAt: NOW - 1000, leftovers: true}); + assert.deepEqual(bar.handleKey(k('text', 'x'), [left], () => undefined, NOW), {kind: 'stop', taskId: 't1', level: 'kill'}); + assert.deepEqual(bar.handleKey(k('delete'), [left], () => undefined, NOW), {kind: 'none', message: 'its processes still run; x kills them, then Delete removes it'}); + assert.match(stripAnsi(renderTaskBar(bar, [left], {width: 120, now: NOW, safe: true, color: false, next: () => undefined})[0]!), /x kill what is left · Esc back/u); + const killed = {...left, stopRequested: 'kill' as const}; + assert.equal(bar.handleKey(k('text', 'x'), [killed], () => undefined, NOW).kind, 'none', 'one kill request; then it waits'); + const manager = new BackgroundManager(); + const lines = manager.render([left], () => undefined, DEFAULT_PREFERENCES(), {width: 100, rows: 20, now: NOW, color: false, safe: true}); + assert.ok(lines.some(line => line.includes('processes of this task still run; x kills them'))); +}); + +test('narrow terminals: the task stays named, the way back stays shown, and removal keeps the selection in place', () => { + const bar = new TaskBarController(); + bar.focused = true; + const tasks = [task('t1', {state: 'stopping', stopRequested: 'terminate', stopRequestedAt: NOW - 5000}), task('t2'), task('t3', {command: 'npm run dev', family: 'npm run dev'})]; + for (const width of [40, 48, 60]) { + const row = stripAnsi(renderTaskBar(bar, tasks, {width, now: NOW, safe: true, color: false, next: () => 'kill'})[0]!); + assert.ok(displayWidth(row) <= width); + assert.match(row, /adb logcat/u, `${width}: the label survives (${row})`); + } + assert.equal(fitHints(['↑↓ select', 'Enter output', 'x stop', 'Del remove', 'c clear finished', 'Tab preferences', 'Esc close'], 40), '↑↓ select · Enter output · Esc close', 'whole hints are dropped, the way back is kept'); + const manager = new BackgroundManager(); + const footer = stripAnsi(manager.render(tasks, () => undefined, DEFAULT_PREFERENCES(), {width: 40, rows: 12, now: NOW, color: false, safe: true}).at(-1)!); + assert.match(footer, /Esc close$/u, 'the way out is never cut off'); + bar.selectedId = 't2'; + bar.sync(tasks); + bar.sync([tasks[0]!, tasks[2]!]); + assert.equal(bar.selectedId, 't3', 'the neighbour takes the removed task\'s place'); + const expanded = new TaskBarController(true); + assert.match(renderTaskBar(expanded, tasks, {width: 80, now: NOW, safe: false, color: false, upArrow: 'off'})[0]!, /Shift\+↑ or \/background/u, 'the hint names the key that enters the bar'); +}); + +test('output view: a scrolled-back view stays on its lines while the retention limit drops older ones; the progress line shows', () => { + const output = new TaskOutput({lines: 20, bytes: 1024 * 1024}); + for (let i = 1; i <= 20; i++) output.append(`line ${i}\n`); + const view = new TaskOutputView(); + const running = task('t1'); + const options = {width: 60, height: 7, now: NOW, color: false, safe: true}; + view.render(running, output, options); + view.scroll(-8, output, 5); + const before = view.render(running, output, options).slice(1, 6); + assert.deepEqual(before, ['line 8', 'line 9', 'line 10', 'line 11', 'line 12']); + for (let i = 21; i <= 25; i++) output.append(`line ${i}\n`); + assert.deepEqual(view.render(running, output, options).slice(1, 6), before, 'the lines being read do not drift up as old ones are dropped'); + assert.match(view.render(running, output, options)[6]!, /^5 new lines below/u); + for (let i = 26; i <= 40; i++) output.append(`line ${i}\n`); + const overtaken = view.render(running, output, {...options, width: 100}); + assert.equal(overtaken[1], 'line 21', 'once its lines are gone the view shows the oldest kept line'); + assert.match(overtaken[6]!, /older lines dropped \(retention limit\) · End follow · Esc back$/u); + assert.match(view.render(running, output, {...options, width: 44})[6]!, /^20 new lines below · End follow · Esc back$/u, 'narrow: notes go before the keys'); + view.end(output); + output.append('downloading 42%'); + const following = view.render(running, output, options); + assert.equal(following[5], 'downloading 42%', 'the unfinished line is the last row while following'); + assert.equal(following[4], 'line 40'); +}); + +test('/background parsing is linear on a long pasted run of spaces', () => { + const started = performance.now(); + assert.equal(parseBackgroundCommand(`/background ${' '.repeat(100_000)}x${' '.repeat(100_000)}`)?.kind, 'invalid'); + assert.equal(parseBackgroundCommand(`${' '.repeat(100_000)}x`), undefined); + assert.ok(performance.now() - started < 250, `took ${(performance.now() - started).toFixed(0)} ms`); +});