diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..25f636c7 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -278,7 +278,7 @@ import {explainModules, moduleTimings} from '../prompt/moduleReport.js'; import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, nmshCommandNote} from '../input/pasteGuard.js'; import type {BatchShell} from '../input/pasteBatch.js'; import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; -import {batchFromCommands, batchRefusal, batchReviewKey, batchTexts, batchBlockText, createBatchReview, createBatchReviewWithRefusal, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; +import {batchFromCommands, batchRefusal, batchReviewKey, batchTexts, batchBlockText, createBatchReview, createBatchReviewWithRefusal, hasNmshCommand, renderBatchReview, type BatchReviewState} from '../input/BatchReview.js'; import {QUEUE_LIMIT} from '../session/CommandQueue.js'; import {addPin, addRecipe, changeItem, keepProblem, loadPins, pinsPath, removeItem, type PinData, type Recipe} from '../pins/PinStore.js'; import {createPinsPanel, pinsPanelKey, renderPinsPanel, type PinsPanelState} from '../pins/PinsPanel.js'; @@ -2100,6 +2100,11 @@ export class TerminalApp { this.output.addFrontendInteraction('/ps', `Queued ${texts.length} command${texts.length === 1 ? '' : 's'}. They run one at a time in this shell; /queue shows them.`, INFO); } done(); + } else if (action?.kind === 'steps') { + // One at a time (QA F02): each command waits in the composer, unsent, and runs only on the person's Enter. + this.pasteSteps = {items: batchTexts(review), next: 0, running: false}; + done(); + this.offerPasteStep(); } else if (action?.kind === 'block') { done(); void this.runReviewedBlock(batchBlockText(review)); @@ -2161,6 +2166,12 @@ export class TerminalApp { // Any other key (typing, editing) invalidates the paste preview and continues normal processing this.pastePreview = undefined; } + if (key.kind === 'escape' && this.pasteSteps && !this.pasteSteps.running && this.editor.text && !this.hasModalInput()) { + this.stopPasteSteps('Stopped'); + this.editor.clear(); + this.render(); + return; + } if (this.shelf.focused && this.handleShelfKey(key)) return; if (this.background.panel) { this.handleBackgroundPanelKey(key); return; } if (this.background.bar.focused && this.handleBackgroundBarKey(key)) return; @@ -3529,8 +3540,12 @@ export class TerminalApp { if (!(slash?.kind === 'ask' && !this.promptConfiguration.askRecord)) this.sessionSubmissions.push({text: command, slash: Boolean(slash && slash.kind !== 'unknown')}); if (this.sessionSubmissions.length > SESSION_SUBMISSION_LIMIT) this.sessionSubmissions.shift(); + const step = this.pasteSteps && !this.pasteSteps.running ? this.pasteSteps : undefined; + if (step) step.running = true; if (slash) { await this.runSlash(command, slash); + // NMSh's own command has done its part; one that started a shell command is followed at its prompt. + if (step && step === this.pasteSteps && !this.running) this.pasteStepDone(true); this.render(); return; } @@ -4347,6 +4362,12 @@ export class TerminalApp { } private onShellPrompt(exitCode: number, cwd: string, at = Date.now()): void { + const step = this.pasteSteps?.running && this.running ? this.pasteSteps : undefined; + this.shellPrompt(exitCode, cwd, at); + if (step && step === this.pasteSteps) this.pasteStepDone(exitCode === 0); + } + + private shellPrompt(exitCode: number, cwd: string, at = Date.now()): void { this.completionService.invalidate(); this.shellSuggestions = []; this.lastSuggestionInput = ''; @@ -8228,7 +8249,7 @@ export class TerminalApp { const now = Date.now(); const suggestion = this.clipboardChangeSuggested && !this.running && this.clipboardChangeWatch?.enabled ? [truncateAnsi(`${SUBTLE}Clipboard changed · /ps reads it for review${RESET}`, columns)] : []; - const rows = [...this.clipboardRows(columns), ...suggestion, ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...watchNoticeRows(this.watches.active(), now).map(row => truncateAnsi(row, columns)), ...this.pastePreviewRows(columns)]; + const rows = [...this.clipboardRows(columns), ...suggestion, ...this.sessionNoticeRows(columns), ...this.taskRows(columns), ...watchNoticeRows(this.watches.active(), now).map(row => truncateAnsi(row, columns)), ...this.pastePreviewRows(columns), ...this.pasteStepRows(columns)]; const background = this.backgroundRows(columns); return shelf ? [...rows, ...background, shelf] : [...rows, ...background]; } @@ -8249,7 +8270,7 @@ export class TerminalApp { * show the review. A boundary the shell does not accept means command separation is refused with a clear explanation, * while still providing Run as block, Edit, or Cancel choices. */ - private async openBatchReview(text: string): Promise { + private async openBatchReview(text: string, current: () => boolean = () => this.pastePreview?.text === text): Promise { const shell = this.batchShell(); if (!shell) return; const capacity = Math.max(0, QUEUE_LIMIT - this.queueState.entries.length); @@ -8261,18 +8282,74 @@ export class TerminalApp { return; } const check = await verifyCommands(batchTexts(review), shell); - if (this.stopped || this.pastePreview?.text !== text) return; + if (this.stopped || !current()) return; if (check.ok) { review.note = [review.note, this.queuePolicyNote()].filter(Boolean).join(' · '); this.batchReview = review; } else { this.batchReview = createBatchReviewWithRefusal(text, shell, capacity, check.reason); - this.pastePreview = {...this.pastePreview, batch: false}; + if (this.pastePreview) this.pastePreview = {...this.pastePreview, batch: false}; if (this.pasteReview) this.pasteReview.batch = false; } this.render(); } + /** + * QA F02: Enter on text the shell grammar splits into several statements, one of them an NMSh command (a pasted + * `cd …` then `/modules`, four `/track` lines, or the same typed with Ctrl+J). Submitted as it is, the shell would get + * NMSh's lines (zsh: "no such file or directory: /modules") or the block would be one broken slash command. Nothing + * is submitted or queued: the text opens the reviewed list, whose one-at-a-time choice offers each command in turn. + */ + private reviewMixedBlock(text: string): boolean { + const shell = this.batchShell(); + if (!shell || !text.includes('\n')) return false; + const review = createBatchReview(text, shell, QUEUE_LIMIT); + if (!review || !hasNmshCommand(review)) return false; + this.editor.clear(); + this.composerHistory.reset(); + this.runningDraft = undefined; + this.pendingMixedBlock = text; + void this.openBatchReview(text, () => this.pendingMixedBlock === text).finally(() => { if (this.pendingMixedBlock === text) this.pendingMixedBlock = undefined; }); + this.render(); + return true; + } + + /** The next pasted command, unsent, when the composer is free; the last one ends the list. */ + private offerPasteStep(): void { + const steps = this.pasteSteps; + if (!steps || steps.running || this.running || this.editor.text) return; + const item = steps.items[steps.next]; + if (item === undefined) { this.pasteSteps = undefined; return; } + steps.next += 1; + this.editor.insertPaste(item); + this.composerHistory.reset(); + } + + /** A pasted command finished: the next is offered after a success; a failure stops the rest, as the queue pauses. */ + private pasteStepDone(ok: boolean): void { + const steps = this.pasteSteps; + if (!steps) return; + steps.running = false; + if (!ok) { this.stopPasteSteps('That command did not succeed'); return; } + if (steps.next >= steps.items.length) { this.pasteSteps = undefined; return; } + this.offerPasteStep(); + } + + private stopPasteSteps(why: string): void { + const steps = this.pasteSteps; + this.pasteSteps = undefined; + if (!steps) return; + const left = steps.items.length - steps.next; + if (left > 0) this.output.addFrontendInteraction('pasted commands', `${why}; the ${left === 1 ? 'next pasted command was' : `${left} pasted commands after it were`} not offered.`, INFO); + } + + /** The one-at-a-time strip above the composer: which pasted command waits there, and how to stop. */ + private pasteStepRows(columns: number): string[] { + const steps = this.pasteSteps; + if (!steps || steps.running || !this.editor.text || this.running) return []; + return [truncateAnsi(`${SUBTLE}Pasted command ${steps.next} of ${steps.items.length} · Enter runs it · Esc stops${RESET}`, columns)]; + } + /** * Run the reviewed paste as one outer execution in the real shell. * Grouped blocks remain one observed outer execution. @@ -9333,6 +9410,10 @@ export class TerminalApp { /** The one place the clipboard is read (only /ps calls it). */ private readClipboardText: () => Promise = () => readClipboard(); private pasteReview?: PasteReviewState; + /** A mixed block from the composer whose reviewed list is being prepared (QA F02). */ + private pendingMixedBlock?: string; + /** Reviewed commands offered one at a time in the composer (QA F02); `running`: the offered one was entered. */ + private pasteSteps?: {items: string[]; next: number; running: boolean}; /** Short presentation transitions (launch, completion materialization, Block Seal, Semantic Echo, prompt morph). */ private readonly transitions = new Transitions(() => this.promptConfiguration.motion, () => ({reducedMotion: !this.decorativeMotionAllowed(), effectsOff: false, color: colorLevel() !== 'none'})); @@ -11818,10 +11899,12 @@ export class TerminalApp { return; } if (!text.trim()) return; + if (this.reviewMixedBlock(text)) return; this.enqueue(text); return; } if (intent === 'send') return; + if (this.reviewMixedBlock(text)) return; if (this.pendingQueuePause) { this.render(); return; } // An empty Enter where the queue offers something arms its review; nothing runs yet. const affordance = intent === 'enter' && !text.trim() ? this.queueAffordance() : undefined; diff --git a/src/input/BatchReview.ts b/src/input/BatchReview.ts index 3294e96f..492b3bd5 100644 --- a/src/input/BatchReview.ts +++ b/src/input/BatchReview.ts @@ -2,6 +2,7 @@ import type {Key} from '../terminal/keys.js'; import {renderControlRows} from '../ui/controls.js'; import {focusForeground, foreground, UI_COLORS} from '../ui/palette.js'; import {GLYPHS} from '../ui/glyphs.js'; +import {isSlashInput, parseSlashCommand} from '../commands/slashCommands.js'; import {classifyCommand, KIND_LABELS, primaryKind, splitCommands, type PasteKind} from './pasteGuard.js'; import {displaySafe} from './PasteReview.js'; import {splitBatch, type BatchCommand, type BatchShell} from './pasteBatch.js'; @@ -17,8 +18,9 @@ export interface BatchEntry extends BatchCommand { kinds: PasteKind[]; } -export type BatchChoice = 'block' | 'queue' | 'edit' | 'cancel'; -export const BATCH_CHOICES: readonly BatchChoice[] = ['block', 'queue', 'edit', 'cancel']; +/** steps: one at a time through the composer, for a list with NMSh commands (QA F02). */ +export type BatchChoice = 'block' | 'queue' | 'steps' | 'edit' | 'cancel'; +export const BATCH_CHOICES: readonly BatchChoice[] = ['block', 'queue', 'steps', 'edit', 'cancel']; export interface BatchReviewState { /** The paste exactly as it arrived. */ @@ -35,7 +37,7 @@ export interface BatchReviewState { title?: string; /** What the commands expect (the recipe's folder and the current one), shown above the list. */ note?: string; - /** Active execution choice: 'block' | 'queue' | 'edit' | 'cancel'. */ + /** Active execution choice. */ choice?: BatchChoice; /** Refusal reason if separation into separate commands is refused or unsafe. */ refusal?: string; @@ -45,6 +47,7 @@ export interface BatchReviewState { export type BatchReviewAction = | {kind: 'queue'} + | {kind: 'steps'} | {kind: 'block'} | {kind: 'text'} | {kind: 'edit'} @@ -92,19 +95,29 @@ export function entryKinds(text: string, depth = 0): PasteKind[] { return [...kinds]; } +/** + * A statement that is one of NMSh's own commands (QA F02). The shell would treat `/modules` as a path, and a block that + * starts with one is not a slash command either, so a list holding one is never run as a block or queued for the shell. + * Comment lines before it belong to the statement; a slash line inside a heredoc or a quote is the statement's text. + */ +export function isNmshStatement(text: string): boolean { + const command = text.split('\n').filter(line => !/^\s*#/u.test(line)).join('\n').trim(); + if (!isSlashInput(command)) return false; + const slash = parseSlashCommand(command); + return Boolean(slash && slash.kind !== 'unknown'); +} + +/** Any of the reviewed commands is NMSh's own; a refused paste counts any line that is one by itself. */ +export function hasNmshCommand(state: Pick): boolean { + return state.entries.some(entry => isNmshStatement(entry.text) || (state.refusal !== undefined && entry.text.split('\n').some(isNmshStatement))); +} + /** The paste as separate commands, when the shell grammar says it safely can be (two or more); undefined means "keep it as one block". */ export function createBatchReview(source: string, shell: BatchShell, capacity: number): BatchReviewState | undefined { const result = splitBatch(source, shell); if (!result.ok || result.commands.length < 2) return undefined; - return { - source, - entries: result.commands.map(command => ({...command, kinds: entryKinds(command.text)})), - cursor: 0, - top: 0, - capacity, - choice: 'queue', - verifiedCount: result.commands.length, - }; + const entries = result.commands.map(command => ({...command, kinds: entryKinds(command.text)})); + return {source, entries, cursor: 0, top: 0, capacity, choice: hasNmshCommand({entries}) ? 'steps' : 'queue', verifiedCount: result.commands.length}; } /** @@ -112,33 +125,33 @@ export function createBatchReview(source: string, shell: BatchShell, capacity: n */ export function createBatchReviewWithRefusal(source: string, shell: BatchShell, capacity: number, refusal?: string): BatchReviewState { const result = splitBatch(source, shell); - if (result.ok && result.commands.length >= 2 && !refusal) { - return { - source, - entries: result.commands.map(command => ({...command, kinds: entryKinds(command.text)})), - cursor: 0, - top: 0, - capacity, - choice: 'queue', - verifiedCount: result.commands.length, - }; - } + if (result.ok && result.commands.length >= 2 && !refusal) return createBatchReview(source, shell, capacity)!; const lines = source.split('\n'); const reason = refusal ?? (result.ok ? 'It is a single command.' : result.reason); + const entries = [{text: source, firstLine: 1, lastLine: lines.length, kinds: entryKinds(source)}]; return { source, - entries: [{text: source, firstLine: 1, lastLine: lines.length, kinds: entryKinds(source)}], + entries, cursor: 0, top: 0, capacity, - choice: 'block', + // A block holding an NMSh command line is never offered to the shell as one: only Edit and Cancel remain. + choice: hasNmshCommand({entries, refusal: reason}) ? 'edit' : 'block', refusal: reason, verifiedCount: result.ok ? result.commands.length : 1, }; } +/** The choice Enter acts on. A list with NMSh commands only ever has its own choices; otherwise as before. */ +function activeChoice(state: BatchReviewState, choices: readonly BatchChoice[]): BatchChoice { + const choice = state.choice ?? (state.refusal ? 'block' : 'queue'); + return hasNmshCommand(state) && !choices.includes(choice) ? choices[0]! : choice; +} + /** Available execution choices for the given batch review state. */ export function batchAvailableChoices(state: BatchReviewState): BatchChoice[] { + // NMSh's commands run in NMSh, one at a time; the shell (a block, its queue) never gets them. + if (hasNmshCommand(state)) return state.refusal ? ['edit', 'cancel'] : ['steps', 'edit', 'cancel']; const choices: BatchChoice[] = ['block']; const canQueue = !state.refusal && state.entries.length >= 2 && state.entries.length <= state.capacity; if (canQueue) choices.push('queue'); @@ -183,7 +196,7 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number }; const choices = batchAvailableChoices(state); - const currentChoice = state.choice ?? (state.refusal ? 'block' : 'queue'); + const currentChoice = activeChoice(state, choices); const cycleChoice = (step: number) => { let idx = choices.indexOf(currentChoice); if (idx === -1) idx = 0; @@ -206,6 +219,7 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number return state.entries.length && state.entries.length <= state.capacity ? {kind: 'queue'} : undefined; } if (currentChoice === 'block') return batchBlockText(state).trim() ? {kind: 'block'} : undefined; + if (currentChoice === 'steps') return state.entries.length ? {kind: 'steps'} : undefined; if (currentChoice === 'edit') return {kind: 'edit'}; if (currentChoice === 'cancel') return {kind: 'cancel'}; return undefined; @@ -220,20 +234,20 @@ export function batchReviewKey(state: BatchReviewState, key: Key, height: number else if (key.kind === 'delete' || key.kind === 'backspace') remove(state); else if (key.kind === 'text') { const value = key.value.toLowerCase(); - if (value === '1') { state.choice = 'block'; return undefined; } + if (value === '1') { if (choices.includes('block') || choices.includes('steps')) state.choice = choices.includes('steps') ? 'steps' : 'block'; return undefined; } if (value === '2') { if (choices.includes('queue')) state.choice = 'queue'; return undefined; } if (value === '3') { state.choice = 'edit'; return undefined; } if (value === '4') { state.choice = 'cancel'; return undefined; } - if (value === 'b') { state.choice = 'block'; return undefined; } + if (value === 'b') { if (choices.includes('block')) state.choice = 'block'; return undefined; } if (value === 'x') remove(state); else if (value === 'k') move(-1); else if (value === 'j') move(1); else if (value === 't') return {kind: 'text'}; else if (value === 'e' && state.entries.length) return {kind: 'edit'}; - else if (value === 'q' && state.entries.length && state.entries.length <= state.capacity && !state.refusal) return {kind: 'queue'}; + else if (value === 'q' && choices.includes('queue') && state.entries.length && state.entries.length <= state.capacity) return {kind: 'queue'}; else if (value === 'c') return {kind: 'cancel'}; } state.top = Math.max(0, Math.min(state.top, Math.max(0, state.entries.length - rows))); @@ -296,27 +310,38 @@ export function renderBatchReview(state: BatchReviewState, columns: number, heig // Execution choices bar const choices = batchAvailableChoices(state); - const activeChoice = state.choice ?? (state.refusal ? 'block' : 'queue'); + const active = activeChoice(state, choices); const compact = columns < 70; const choicePills: string[] = []; + const nmsh = hasNmshCommand(state); + if (nmsh) { + out.push(` ${subtle}${truncateText(state.refusal ? 'It has NMSh commands, which never go to the shell: edit it, or enter them one by one.' + : 'NMSh commands run in NMSh, not the shell: each command waits in the composer in turn, and runs on your Enter.', width)}${reset}`); + if (!state.refusal) { + const stepsLabel = compact ? `1 One at a time (${count})` : `1 One at a time · ${count} command${count === 1 ? '' : 's'}`; + choicePills.push(active === 'steps' ? `${accent}${GLYPHS.selection} [${stepsLabel}]${reset}` : `${subtle}[${stepsLabel}]${reset}`); + } + } - const blockFocused = activeChoice === 'block'; - const blockLabel = compact ? '1 Block' : '1 Run as block'; - choicePills.push(blockFocused ? `${accent}${GLYPHS.selection} [${blockLabel} · 1 outer execution]${reset}` : `${subtle}[${blockLabel}]${reset}`); + if (!nmsh) { + const blockFocused = active === 'block'; + const blockLabel = compact ? '1 Block' : '1 Run as block'; + choicePills.push(blockFocused ? `${accent}${GLYPHS.selection} [${blockLabel} · 1 outer execution]${reset}` : `${subtle}[${blockLabel}]${reset}`); - const queueFocused = activeChoice === 'queue'; - const canQueue = choices.includes('queue'); - if (canQueue) { - const queueLabel = compact ? `2 Queue (${count})` : `2 Queue separately · ${count} statement${count === 1 ? '' : 's'}`; - choicePills.push(queueFocused ? `${accent}${GLYPHS.selection} [${queueLabel}]${reset}` : `${subtle}[2 Queue]${reset}`); - } else { - choicePills.push(`${subtle}[2 Queue · refused]${reset}`); + const queueFocused = active === 'queue'; + const canQueue = choices.includes('queue'); + if (canQueue) { + const queueLabel = compact ? `2 Queue (${count})` : `2 Queue separately · ${count} statement${count === 1 ? '' : 's'}`; + choicePills.push(queueFocused ? `${accent}${GLYPHS.selection} [${queueLabel}]${reset}` : `${subtle}[2 Queue]${reset}`); + } else { + choicePills.push(`${subtle}[2 Queue · refused]${reset}`); + } } - const editFocused = activeChoice === 'edit'; + const editFocused = active === 'edit'; choicePills.push(editFocused ? `${accent}${GLYPHS.selection} [3 Edit]${reset}` : `${subtle}[3 Edit]${reset}`); - const cancelFocused = activeChoice === 'cancel'; + const cancelFocused = active === 'cancel'; choicePills.push(cancelFocused ? `${accent}${GLYPHS.selection} [4 Cancel]${reset}` : `${subtle}[4 Cancel]${reset}`); out.push(` ${choicePills.join(' ')}`); @@ -340,16 +365,18 @@ export function renderBatchReview(state: BatchReviewState, columns: number, heig const selected = state.entries[state.cursor]; const notes = 2; - const actionLabel = activeChoice === 'block' + const actionLabel = active === 'steps' + ? 'first command to the composer' + : active === 'block' ? 'run as block' - : activeChoice === 'queue' + : active === 'queue' ? `queue ${count}` - : activeChoice === 'edit' + : active === 'edit' ? 'edit in composer' : 'cancel'; const controls = renderControlRows([['↑↓', 'select'], ['←→/Tab', 'choice'], ['Enter', actionLabel], ['e', 'edit as text'], ['t', 'original text'], ['Esc', 'back']], columns); - if (activeChoice === 'block' && state.entries.length <= 1) { + if (active === 'block' && state.entries.length <= 1) { out.push('', ` ${subtle}Entire block, exactly as it will run as 1 outer execution${reset}`); const wrapped = batchBlockText(state).split('\n').flatMap(line => wrapRows(line, Math.max(8, width - 4))); const room = Math.max(2, height - out.length - notes - controls.length - 1); @@ -366,11 +393,11 @@ export function renderBatchReview(state: BatchReviewState, columns: number, heig if (wrapped.length > shown) out.push(` ${failure}… ${wrapped.length - shown} more row${wrapped.length - shown === 1 ? "" : "s"} hidden; still part of the command${reset}`); } out.push(''); - if (activeChoice === 'block') { + if (active === 'block') { out.push(` ${subtle}${truncateText('Nothing runs until you press Enter. The shell runs 1 outer execution.', width)}${reset}`); - } else if (activeChoice === 'edit') { + } else if (active === 'edit') { out.push(` ${subtle}${truncateText('Press Enter to open this text in the composer for editing. Nothing will be executed.', width)}${reset}`); - } else if (activeChoice === 'cancel') { + } else if (active === 'cancel') { out.push(` ${subtle}${truncateText('Press Enter to cancel and discard this paste. Nothing will be executed.', width)}${reset}`); } else if (count > state.capacity) { out.push(` ${failure}${truncateText(`The queue has room for ${state.capacity} more; remove ${count - state.capacity} to queue these.`, width)}${reset}`); diff --git a/tests/pasteSlashCommands.test.ts b/tests/pasteSlashCommands.test.ts new file mode 100644 index 00000000..783552e1 --- /dev/null +++ b/tests/pasteSlashCommands.test.ts @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type {QueueOp} from '../src/session/CommandQueue.js'; +import {batchAvailableChoices, batchReviewKey, createBatchReview, renderBatchReview} from '../src/input/BatchReview.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +/** + * QA F02 (v0.22 physical QA, Ghostty + zsh, 68c1cbb7): a pasted block mixing NMSh commands with shell commands. + * A: four `/track …` lines gave NMSh's unknown-command error: the whole block was parsed as ONE slash command. + * B: `cd modules/bun-app` + `/modules`: the cd ran, then zsh said "no such file or directory: /modules": the whole + * block went to the shell, NMSh's line with it. + * Enter submitted the composer's text as one unit. Now a block the shell grammar splits into several statements, any of + * them an NMSh command, is never submitted or queued as it is: it opens the reviewed list, and "one at a time" puts each + * command in the composer, unsent, in order; each runs only on its own Enter, the next is offered only after the one + * before succeeded, and Esc stops. Multi-line shell text (scripts, heredocs, Ctrl+J) is unchanged. + */ + +const A = '/track ls\n/track df -h\n/track cat track/f.txt\n/track ls missing'; +const B = 'cd modules/bun-app\n/modules'; +const settled = () => new Promise(done => setImmediate(done)); + +function fixture() { + const frame = executionFrameFixture(110, 34); + // zsh's grammar, which every CI runner has: the reviewed list is offered only after the shell itself parse-checks + // each command (fail closed), and the fixture's own shell (Fish) is not installed everywhere. + frame.app['shellId'] = 'zsh'; + const submitted: string[] = []; + const slashes: string[] = []; + const queueOps: QueueOp[] = []; + Object.defineProperty(frame.session, 'submit', {value: (text: string) => { submitted.push(text); }, configurable: true}); + Object.defineProperty(frame.session, 'queue', {value: (op: QueueOp) => { queueOps.push(op); frame.owner.request(op); }, configurable: true}); + // NMSh commands are recorded, not run: what matters is that NMSh, not the shell, gets each one, alone. + frame.app['runSlash'] = async (command: string) => { slashes.push(command); }; + const key = (value: Parameters[0]) => frame.app['handleKey'](value); + const enter = () => key({kind: 'enter'}); + const composer = () => frame.app['editor'].text as string; + const adds = () => queueOps.filter(op => op.op === 'add').map(op => (op as {text: string}).text); + const reviewing = async () => { + // The fixture's clock is frozen: bounded by rounds (the review waits for the shell's parse check). + for (let round = 0; !frame.app['batchReview'] && round < 800; round += 1) await new Promise(done => setTimeout(done, 10)); + assert.ok(frame.app['batchReview'], 'the reviewed list opened'); + }; + /** Paste, then Enter twice: the preview inserts it, the second Enter is the one QA pressed. */ + const pasteAndEnter = async (text: string) => { key({kind: 'paste', value: text}); enter(); assert.equal(composer(), text); enter(); await settled(); }; + const screen = () => frame.capture().frame.rows.map(row => stripAnsi(row)).join('\n'); + return {...frame, submitted, slashes, adds, key, enter, composer, reviewing, pasteAndEnter, screen}; +} + +test('review: a list with an NMSh command offers one at a time only, never one block or the shell\'s queue', () => { + for (const source of [A, B]) { + const review = createBatchReview(source, 'zsh', 100)!; + assert.deepEqual(batchAvailableChoices(review), ['steps', 'edit', 'cancel'], source); + assert.equal(review.choice, 'steps'); + for (const shortcut of ['1', 'b', 'q', '2']) { + assert.equal(batchReviewKey(review, {kind: 'text', value: shortcut}, 26), undefined, `${shortcut} does nothing`); + assert.notEqual(review.choice, 'block'); + assert.notEqual(review.choice, 'queue'); + } + review.choice = 'steps'; + assert.deepEqual(batchReviewKey(review, {kind: 'enter'}, 26), {kind: 'steps'}); + } + // A slash line inside a heredoc is the heredoc's text (zsh, Bash), not a statement. + for (const shell of ['zsh', 'bash'] as const) { + const heredoc = createBatchReview('cd /tmp\ncat < entry.text), ['cd /tmp', 'cat < { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + assert.deepEqual(f.submitted, [], 'nothing reached the shell'); + assert.deepEqual(f.slashes, []); + f.enter(); + assert.equal(f.app['batchReview'], undefined); + assert.equal(f.composer(), 'cd modules/bun-app', 'the first command waits in the composer, unsent'); + assert.match(f.screen(), /1 of 2/u); + assert.deepEqual(f.submitted, []); + f.enter(); await settled(); + assert.equal(f.submitted.length, 1); + assert.match(f.submitted[0]!, /cd modules\/bun-app/u); + assert.doesNotMatch(f.submitted[0]!, /\/modules\b(?!\/)/u, 'the shell never gets the NMSh line'); + f.begin('cd modules/bun-app'); + f.complete(0); await settled(); + assert.equal(f.composer(), '/modules', 'after it succeeded, the next one is offered, unsent'); + assert.deepEqual(f.slashes, []); + f.enter(); await settled(); + assert.deepEqual(f.slashes, ['/modules'], 'NMSh runs its own command'); + assert.equal(f.submitted.length, 1, 'still only the cd reached the shell'); + assert.equal(f.composer(), ''); + assert.doesNotMatch(f.screen(), /of 2/u, 'done: nothing more is offered'); + } finally { f.dispose(); } +}); + +test('F02 A: four `/track` lines pasted and entered: each is NMSh\'s own command, alone, on its own Enter', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(A); + await f.reviewing(); + assert.deepEqual(f.slashes, [], 'not parsed as one broken command'); + f.enter(); + for (const line of A.split('\n')) { + assert.equal(f.composer(), line); + f.enter(); await settled(); + } + assert.deepEqual(f.slashes, A.split('\n')); + assert.deepEqual(f.submitted, []); + } finally { f.dispose(); } +}); + +test('F02: a failed step stops the rest; Esc stops them too; nothing left is run', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + f.enter(); + f.enter(); await settled(); + f.begin('cd modules/bun-app'); + f.complete(1); await settled(); + assert.equal(f.composer(), '', 'the next command is not offered after a failure'); + assert.match(f.screen(), /not offered/u); + assert.deepEqual(f.slashes, []); + // Esc while a step waits in the composer. + await f.pasteAndEnter(B); + await f.reviewing(); + f.enter(); + assert.equal(f.composer(), 'cd modules/bun-app'); + f.key({kind: 'escape'}); + assert.equal(f.composer(), ''); + f.begin('true'); + f.complete(0); await settled(); + assert.equal(f.composer(), '', 'stopped: nothing more is offered'); + assert.equal(f.submitted.length, 1); + } finally { f.dispose(); } +}); + +test('F02: the reviewed list\'s Cancel and Edit run nothing', async () => { + const f = fixture(); + try { + await f.pasteAndEnter(B); + await f.reviewing(); + f.key({kind: 'text', value: 'c'}); + assert.equal(f.app['batchReview'], undefined); + assert.equal(f.composer(), ''); + await f.pasteAndEnter(B); + await f.reviewing(); + f.key({kind: 'text', value: 'e'}); + assert.equal(f.composer(), B, 'Edit puts the text back as it was'); + assert.deepEqual(f.submitted, []); + assert.deepEqual(f.slashes, []); + } finally { f.dispose(); } +}); + +test('F02: typed with Ctrl+J, or entered while a command runs, a mixed block is not submitted or queued either', async () => { + const f = fixture(); + try { + for (const char of 'echo a') f.key({kind: 'text', value: char}); + f.key({kind: 'newline'}); + for (const char of '/track ls') f.key({kind: 'text', value: char}); + f.enter(); await settled(); + await f.reviewing(); + assert.deepEqual(f.submitted, []); + f.key({kind: 'text', value: 'c'}); + f.begin('sleep 5'); + f.key({kind: 'paste', value: B}); f.enter(); f.enter(); await settled(); + await f.reviewing(); + assert.deepEqual(f.adds(), [], 'not queued for the shell'); + } finally { f.dispose(); } +}); + +test('F02: multi-line shell text is unchanged: a script, a heredoc with a slash line inside, and a single NMSh command', async () => { + const f = fixture(); + try { + const script = 'for f in a b; do\n echo $f\ndone'; + f.app['editor'].insertPaste(script); + f.enter(); await settled(); + assert.equal(f.app['batchReview'], undefined); + assert.equal(f.submitted.length, 1, 'a script runs as one command, as before'); + f.complete(0); + // A quoted line is text in every shell. + const quoted = "printf '%s\\n' 'first\n/track ls\nlast'"; + f.app['editor'].insertPaste(quoted); + f.enter(); await settled(); + await new Promise(done => setTimeout(done, 200)); + assert.equal(f.app['batchReview'], undefined, 'a slash line inside a quote is text, not a command'); + assert.equal(f.submitted.length, 2); + f.complete(0); + f.app['editor'].insertPaste('/track ls'); + f.enter(); await settled(); + assert.deepEqual(f.slashes, ['/track ls']); + } finally { f.dispose(); } +});