Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8169bf1
Copy as Report: one or several blocks as a reviewed Markdown or plain…
raiseCatError Oct 10, 2026
eb656f4
Copy as Report: every report is reviewed; changed lines are marked; e…
raiseCatError Oct 10, 2026
1980738
Copy as Report: what is scanned, reviewed and copied is the same text
raiseCatError Oct 10, 2026
3ba2960
Copy as Report: no differential between what is scanned, reviewed, pa…
raiseCatError Oct 10, 2026
79e3f40
Copy as Report: a directory or status with line breaks or backticks c…
raiseCatError Oct 10, 2026
3f97139
Copy as Report tests: no blank line at end of file
raiseCatError Oct 10, 2026
dc82f81
Merge feature/copy-selection into feature/copy-report
raiseCatError Oct 10, 2026
cd98dae
Merge feature/copy-selection into feature/copy-report
raiseCatError Oct 10, 2026
afe7f69
Merge feature/copy-selection into feature/copy-report
raiseCatError Oct 10, 2026
b7c1be8
Merge branch 'fix/workflow-copy-checkpoint' into fix/workflow-report-…
raiseCatError Oct 11, 2026
b6baf78
Verify reviewed report clipboard bytes through the live frontend
raiseCatError Oct 11, 2026
25af5ba
Keep restored report status literal in Markdown
raiseCatError Oct 11, 2026
7173b46
Merge branch 'fix/workflow-copy-resume' into fix/workflow-report-resume
raiseCatError Oct 11, 2026
768183b
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 11, 2026
ee5483e
test: align live report assertions with literal Markdown status
raiseCatError Oct 11, 2026
d51e80f
merge: propagate portable verification fixtures
raiseCatError Oct 11, 2026
26a2a7f
merge: propagate isolated home and completion fixtures
raiseCatError Oct 11, 2026
80bd0cf
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 11, 2026
640a228
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 12, 2026
736100e
Keep redaction inside report data and retain a visible review body
raiseCatError Oct 12, 2026
dd9aa85
Keep report text reviewable at extreme terminal widths
raiseCatError Oct 12, 2026
d12d23b
Bound report delimiter sizing for backtick-rich data
raiseCatError Oct 12, 2026
9fb09c9
Wait for a valid published capture PID before testing cleanup
raiseCatError Oct 12, 2026
ab49665
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 12, 2026
5a3f59a
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 12, 2026
e1f93c6
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 12, 2026
5a6300b
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 12, 2026
623fc81
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 12, 2026
7bf0dbe
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 13, 2026
0fd61cd
Merge branch 'fix/workflow-amber' into fix/workflow-coral
raiseCatError Oct 13, 2026
546768b
Merge commit '2612ac5fada980edd6894eb0bde85b57eb9d5421' into fix/work…
raiseCatError Oct 13, 2026
333e9e8
Merge commit '1a119fa0f571c1ea8bbdb9faf765047801409168' into fix/work…
raiseCatError Oct 13, 2026
648002f
Merge commit 'bb6a441ee4485c3206e4dd46d7c765b8cc3efa93' into fix/work…
raiseCatError Oct 13, 2026
288dbdc
Merge verified fullscreen fixture correction into coral
raiseCatError Oct 13, 2026
85d1495
test: preserve deterministic startup checks in coral
raiseCatError Oct 13, 2026
0d092ac
fix: preserve startup signal cleanup in coral
raiseCatError Oct 13, 2026
690ff65
Merge commit 'ce6cee16a53a44d03d906d0af6db41f796defa3d' into fix/work…
raiseCatError Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 67 additions & 6 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeB
import {createRowPanel, renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js';
import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection} from '../clipboard/copySelection.js';
import {copyPickerKey, createCopyPicker, renderCopyPicker, type CopyPickerState} from '../ui/CopyPicker.js';
import {DEFAULT_REPORT_OPTIONS, type ReportFormat} from '../clipboard/report.js';
import {applyReportEdit, createReportReview, renderReportReview, reportReviewKey, type ReportReviewState} from '../ui/ReportReview.js';
import {configuredEditor} from '../host/HostActions.js';
import {createStripStudio, renderStripStudio, stripStudioKey, type StripStudioState} from '../status/StripStudio.js';
import {configureListKey, renderConfigureList, type ConfigureListState} from '../tools/config/ConfigureList.js';
import {registryFacts, toolConfigEntry} from '../tools/config/registry.js';
Expand Down Expand Up @@ -89,7 +92,7 @@ import {
SETTINGS_ROWS,
} from '../ui/SettingsPanel.js';
import {OUTPUT_FOLDING_MODES} from '../output/FoldPolicy.js';
import {accessSync, appendFileSync, constants, existsSync, readFileSync, realpathSync, rmSync, statSync} from 'node:fs';
import {accessSync, appendFileSync, constants, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync} from 'node:fs';
import {shouldProbeGraphics} from '../host/capabilities.js';
import {basename, delimiter, join, resolve as resolvePath} from 'node:path';
import {spawn} from 'node:child_process';
Expand Down Expand Up @@ -1431,15 +1434,28 @@ export class TerminalApp {
}
if (this.copyPicker) {
const action = copyPickerKey(this.copyPicker, key);
if (action?.kind === 'close') { this.copyPicker = undefined; this.returnFromPanel(); }
else if (action?.kind === 'copy') {
const report = this.copyPickerReport;
if (action?.kind === 'close') { this.copyPicker = undefined; this.copyPickerReport = undefined; this.returnFromPanel(); }
else if (action?.kind === 'copy' || action?.kind === 'report') {
this.copyPicker = undefined;
this.copyPickerReport = undefined;
this.returnFromPanel();
void this.copyRecords(action.records, action.includeStatus);
if (action.kind === 'report' || report) this.startReport(action.records, report ?? 'markdown');
else void this.copyRecords(action.records, action.includeStatus);
}
this.render();
return;
}
if (this.reportReview) {
const {columns, rows} = this.dimensions();
const action = reportReviewKey(this.reportReview, key, rows - 4, columns);
const review = this.reportReview;
if (action?.kind === 'close') { this.reportReview = undefined; this.returnFromPanel(); }
else if (action?.kind === 'copy') { this.reportReview = undefined; this.returnFromPanel(); void this.copyReportText(action.text, review.records); }
else if (action?.kind === 'edit') void this.editReport(review);
this.render();
return;
}
if (this.rowPanel) {
const action = rowPanelKey(this.rowPanel, key, this.promptConfiguration);
if (action?.kind === 'close') { this.rowPanel = undefined; this.returnFromPanel(); }
Expand Down Expand Up @@ -2641,6 +2657,7 @@ export class TerminalApp {
if (!record) return; // A clear/restore must never act on stale screen coordinates.
const includeStatus = this.promptConfiguration.copy.includeStatus;
if (action === 'copyOutput') await this.copyRecords([record], includeStatus);
else if (action === 'copyReport') this.startReport([record], 'markdown');
else if (action === 'copyBoth') await this.copyRecords([record], includeStatus, record.command);
else if (action === 'copyCommand') {
// The command text only: completion status never applies here.
Expand Down Expand Up @@ -2878,19 +2895,58 @@ export class TerminalApp {
this.render();
}

/**
* Copy as Report. A report is made to be shared, and pattern-based redaction cannot promise to catch everything,
* so every report goes through its review (Enter copies): it never reaches the clipboard unseen. Oldest first.
*/
private startReport(records: readonly CompletedCommand[], format: ReportFormat): void {
this.panelOrigin = undefined;
this.reportReview = createReportReview(records, {...DEFAULT_REPORT_OPTIONS, format, home: homedir()}, Boolean(configuredEditor(process.env)));
this.render();
}

/** The report's clipboard write: the same tool, feedback and Auto-expand rule as every other copy. */
private async copyReportText(text: string, records: readonly CompletedCommand[]): Promise<void> {
try { await writeClipboard(text); }
catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); return; }
if (this.promptConfiguration.copy.autoExpand) this.output.expandBlocks(records.filter(record => record.output.trim()).map(record => record.startId));
const stats = copyStats(text);
this.noteClipboard(`Copied report · ${records.length} command${records.length === 1 ? '' : 's'} · ${stats.characters.toLocaleString()} characters`, 'success');
}

/** e in the report review: the text in your editor (a private temporary file), back into the review when it exits. */
private async editReport(review: ReportReviewState): Promise<void> {
const editor = configuredEditor(process.env);
if (!editor || !this.hostTerminalFree()) return;
const directory = mkdtempSync(join(tmpdir(), 'nmsh-report-'));
const file = join(directory, review.options.format === 'markdown' ? 'report.md' : 'report.txt');
try {
writeFileSync(file, review.text, {mode: 0o600});
const code = await this.withHostTerminal(() => new Promise<number | null>(done => {
const child = spawn(editor[0]!, [...editor.slice(1), file], {stdio: 'inherit'});
child.once('error', () => done(null));
child.once('close', exit => done(exit));
}));
if (code === 0 && this.reportReview === review) applyReportEdit(review, readFileSync(file, 'utf8'));
} catch { /* The review keeps the text it had. */ }
finally { rmSync(directory, {recursive: true, force: true}); this.render(); }
}

/** /copy and /cp: one parser, the configured default, and the same clipboard write as the picker and block Copy. */
private async copySlash(command: string, args: string): Promise<void> {
const parsed = parseCopyArgs(args);
if (!parsed.ok) { this.output.addFrontendInteraction(command.trim(), parsed.error, ERROR); return; }
const {selector, status} = parsed.request;
const {selector, status, report} = parsed.request;
const includeStatus = status ?? this.promptConfiguration.copy.includeStatus;
if (selector.kind === 'picker' || (selector.kind === 'default' && this.promptConfiguration.copy.mode === 'picker')) {
this.panelOrigin = undefined;
this.copyPicker = createCopyPicker(this.output.recentShellCommands(), includeStatus);
this.copyPickerReport = report;
return;
}
const resolved = resolveCopySelection(selector.kind === 'default' ? {kind: 'latest', count: 1} : selector, this.output.recentShellCommands());
if (!resolved.ok) { this.output.addFrontendInteraction(command.trim(), resolved.error, ERROR); return; }
if (report) { this.startReport(resolved.records, report); return; }
await this.copyRecords(resolved.records, includeStatus);
}

Expand Down Expand Up @@ -4147,7 +4203,7 @@ export class TerminalApp {
}

private get settingsPanelActive(): boolean {
return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState
return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.reportReview || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || this.paletteState || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState
|| this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.pasteReview || this.misePanel || this.keepAwakePanel);
}

Expand Down Expand Up @@ -4183,6 +4239,7 @@ export class TerminalApp {
if (this.stripStudio) return renderStripStudio(this.stripStudio, this.promptConfiguration, columns, this.dimensions().rows,
(configuration, width) => this.statusStripRow(width, configuration));
if (this.copyPicker) return renderCopyPicker(this.copyPicker, columns, this.dimensions().rows - 4);
if (this.reportReview) return renderReportReview(this.reportReview, columns, this.dimensions().rows - 4);
if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.rowPanel.rowIds.includes('copyMode') ? []
: this.promptConfiguration.statusStrip.enabled ? [this.statusStripRow(columns - 2)] : [' (Status strip Off)']);
if (this.configureList) return renderConfigureList(this.configureList, registryFacts(), columns, this.dimensions().rows);
Expand Down Expand Up @@ -5031,6 +5088,10 @@ export class TerminalApp {
private rowPanel?: RowPanelState;
/** /copy as an Interactive Picker (Settings → Copy, or /copy ui). */
private copyPicker?: CopyPickerState;
/** /copy picks headed for a report (`/copy ui --report`). */
private copyPickerReport?: ReportFormat;
/** The review before a report is copied. */
private reportReview?: ReportReviewState;
private clipboardNote?: {text: string; tone: 'success' | 'info' | 'error'; until: number};
private clipboardNoteTimer?: ReturnType<typeof setTimeout>;
/** The block whose Copy control just succeeded (by startId), shown as "Copied" until `until`. */
Expand Down
4 changes: 2 additions & 2 deletions src/ask/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,8 @@ export const CONCEPTS: readonly Concept[] = [
aliases: ['glyphs', 'glyph style', 'nerd font', 'nerd fonts', 'icons', 'symbols'],
description: 'Glyph style picks Nerd Font symbols or safe terminal symbols for icons and prompt shapes.'},
{id: 'copy', label: 'Copy output', support: 'actionable', open: '/copy', covers: ['/copy', '/cp', 'settings:Copy'],
aliases: ['copy output', 'copy the output', 'copy last output', 'copy latest output', 'copy several outputs', 'copy completion status', 'quick copy', 'copy picker'],
description: '/copy copies the latest command output as plain text (or opens a picker, in Settings → Copy); /copy N copies an earlier one, /copy -N the latest N, /copy 2-4 or /copy 1,3,5 several, oldest first. --status adds each command\'s completion status after its output; --no-status leaves it out.'},
aliases: ['copy output', 'copy the output', 'copy last output', 'copy latest output', 'copy several outputs', 'copy completion status', 'quick copy', 'copy picker', 'copy as report', 'copy report', 'bug report', 'share output'],
description: '/copy copies the latest command output as plain text (or opens a picker, in Settings → Copy); /copy N copies an earlier one, /copy -N the latest N, /copy 2-4 or /copy 1,3,5 several, oldest first. --status adds each command\'s completion status after its output; --no-status leaves it out. --report (or --report=plain) copies a reviewed Markdown report: command, status, exit code, duration and output, with optional redaction.'},
{id: 'homebrew', label: 'Homebrew packages', support: 'actionable', open: '/tools', covers: [],
aliases: ['homebrew', 'brew packages', 'brew', 'formulae', 'casks', 'package manager'],
description: 'Ask can inspect Homebrew: installed packages, versions, search, info, dependencies and what is outdated, and install, upgrade or uninstall after showing the exact brew command and your Yes. Ownership is only claimed from Homebrew\'s own evidence.'},
Expand Down
13 changes: 11 additions & 2 deletions src/clipboard/copySelection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {CompletedCommand} from '../output/OutputBuffer.js';
import {parseReportFlag, type ReportFormat} from './report.js';

/**
* Which completed commands /copy takes, by recency (1 is the latest), and whether NMSh's completion status follows
Expand All @@ -14,6 +15,7 @@ import type {CompletedCommand} from '../output/OutputBuffer.js';
* - `/copy 1,3,5` those outputs; items may be ranges (`1,3-5`)
* - `--status` / `--no-status` override the "Include completion status" setting for this copy only. Flags always
* start with two dashes, so they never read as a negative count.
* - `--report` (Markdown) or `--report=plain`: the selection as a structured report (see report.ts).
* `/cp` is the same command: it reaches this parser with the same arguments.
*/
export const COPY_MAX_INDEX = 999;
Expand All @@ -30,17 +32,23 @@ export interface CopyRequest {
selector: CopySelector;
/** An explicit --status / --no-status; undefined follows the setting. */
status?: boolean;
/** Copy as a report in this format instead of plain output. */
report?: ReportFormat;
}

export type CopyArgs = {ok: true; request: CopyRequest} | {ok: false; error: string};

const USAGE = 'Use /copy, /copy latest, /copy ui, /copy N, /copy -N, /copy A-B or /copy 1,3-5, optionally with --status or --no-status.';
const USAGE = 'Use /copy, /copy latest, /copy ui, /copy N, /copy -N, /copy A-B or /copy 1,3-5, optionally with --status, --no-status or --report[=plain].';

/** The text after `/copy`. Nothing is guessed: anything unrecognised is an error that names the usage. */
export function parseCopyArgs(text: string): CopyArgs {
let status: boolean | undefined;
let report: ReportFormat | undefined;
const selectors: string[] = [];
for (const token of text.trim().split(/\s+/u).filter(Boolean)) {
const format = parseReportFlag(token);
if (format === 'invalid') return {ok: false, error: `${token}: a report is --report (Markdown) or --report=plain.`};
if (format) { report = format; continue; }
if (token === '--status' || token === '--no-status') {
const value = token === '--status';
if (status !== undefined && status !== value) return {ok: false, error: 'Use either --status or --no-status, not both.'};
Expand All @@ -49,7 +57,8 @@ export function parseCopyArgs(text: string): CopyArgs {
else selectors.push(token);
}
if (selectors.length > 1) return {ok: false, error: `One selection at a time (write lists with commas: /copy 1,3,5). ${USAGE}`};
const withStatus = (selector: CopySelector): CopyArgs => ({ok: true, request: {selector, ...(status === undefined ? {} : {status})}});
if (report && status !== undefined) return {ok: false, error: 'A report always includes each command\'s status; leave out --status and --no-status.'};
const withStatus = (selector: CopySelector): CopyArgs => ({ok: true, request: {selector, ...(status === undefined ? {} : {status}), ...(report ? {report} : {})}});
const selector = selectors[0];
if (selector === undefined) return withStatus({kind: 'default'});
if (selector === 'ui') return withStatus({kind: 'picker'});
Expand Down
Loading
Loading