Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c879fdc
Compare output: what changed between two runs, computed when asked
raiseCatError Oct 10, 2026
d3c08c7
Merge feature/copy-report into feature/compare-output
raiseCatError Oct 10, 2026
fbbdfcf
Compare output: the copied diff and the comparison report carry only …
raiseCatError Oct 10, 2026
dafd69f
Workflow ledger: stack table and the report and compare security acce…
raiseCatError Oct 10, 2026
0ee60dc
Merge feature/copy-report into feature/compare-output
raiseCatError Oct 10, 2026
36251c7
Merge feature/copy-report into feature/compare-output
raiseCatError Oct 10, 2026
7c8b480
Merge feature/copy-report into feature/compare-output
raiseCatError Oct 10, 2026
b1cdfbe
Merge feature/copy-report into feature/compare-output
raiseCatError Oct 10, 2026
3b7916c
Merge branch 'fix/workflow-report-checkpoint' into fix/workflow-compa…
raiseCatError Oct 11, 2026
5570d34
Sanitize comparison chooser headings and make hidden controls visible
raiseCatError Oct 11, 2026
82726ae
Merge branch 'fix/workflow-report-checkpoint' into fix/workflow-compa…
raiseCatError Oct 11, 2026
1489b8f
Merge branch 'fix/workflow-report-resume' into fix/workflow-compare-r…
raiseCatError Oct 11, 2026
ce6f2f5
Merge branch 'fix/workflow-coral' into fix/workflow-indigo
raiseCatError Oct 11, 2026
eb4fdf9
merge: propagate report and portable verification fixtures
raiseCatError Oct 11, 2026
c6d7514
merge: propagate isolated home and completion fixtures
raiseCatError Oct 11, 2026
0117a34
Merge branch 'fix/workflow-coral' into fix/workflow-indigo
raiseCatError Oct 11, 2026
439b5c8
Merge branch 'fix/workflow-coral' into fix/workflow-indigo
raiseCatError Oct 12, 2026
daeebb7
Merge branch 'fix/workflow-coral' into fix/workflow-indigo
raiseCatError Oct 12, 2026
d7163eb
Redact comparison data before building its reviewed Markdown
raiseCatError Oct 12, 2026
2c478d6
Merge branch 'fix/workflow-coral' into fix/workflow-indigo
raiseCatError Oct 12, 2026
bc55517
Merge branch 'fix/workflow-coral' into fix/workflow-indigo
raiseCatError Oct 12, 2026
2cbbcb7
Use bounded delimiter sizing for comparison reports
raiseCatError Oct 12, 2026
15644ab
Merge branch 'fix/workflow-coral' into fix/workflow-indigo
raiseCatError Oct 12, 2026
c31322a
fix: restore terminal ownership before journal IO
raiseCatError Oct 12, 2026
5bd5b78
test: resolve isolated tmux fixture from host PATH
raiseCatError Oct 12, 2026
032365e
fix: retain durable journal barrier for ordinary startup
raiseCatError Oct 12, 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
21 changes: 21 additions & 0 deletions docs/development/workflow-initiative-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Design and research: [docs/design/terminal-workflow.md](../design/terminal-workf
| `feature/command-queue` | `../notMyShell-command-queue` | #357 (draft, base #348) | complete; full verify green when the machine is not saturated |
| `feature/copy-selection` | `../notMyShell-copy` | #358 (draft, base #357) | copy selection, picker, settings, sticky controls, Smart folding fix |

| `feature/copy-report` | `../notMyShell-report` | #360 (draft, base #358) | Copy as Report; security fixes below |
| `feature/compare-output` | `../notMyShell-compare` | #361 (draft, base #360) | Compare output; contains #360 by merge |

Other open PRs (independent, not part of this stack): #347 agent workspace, #349 notice word wrap, #346 mods.

## Work completed
Expand All @@ -45,6 +48,24 @@ Other open PRs (independent, not part of this stack): #347 agent workspace, #349
| Smart folding: long output folds whatever the command; important lines stay visible | `7a94ce5` | rewritten policy tests incl. 5/50/200/500/5000 lines, wrapped lines, errors mid-log |
| Copy never changes folding; opt-in auto-expand of copied blocks only | `7a94ce5` | `tests/copyFolding.test.ts` |

## Security acceptance (#363), checked against current heads

Verified fixed or not reproducible: parser/redaction differential (skeleton matching with span mapping), token
splitting by zero-width, combining and full-width characters, bidi controls inside key names, private paths,
URL credentials, auth headers, report fence sizing, editing (edited text is rescanned and labelled as not redacted).

Found and fixed in this session (unit tests in `tests/compareSecurity.test.ts` and `tests/copyReport.test.ts`):

| Finding | Fix |
| --- | --- |
| `c` copied the raw unified diff: escape bytes, bidi and invisible characters from program output reached the clipboard | `copyableDiff` applies the report character policy |
| `c` gave no hint that the copy was not reviewed or redacted | the note says what looks sensitive and points to `r` |
| Comparison report fence was sized before invisible characters were removed; hidden characters or U+2028 could close it | the diff is cleaned first, then the fence is sized |
| Side labels (command text) sat in single-backtick inline code | `inlineCode` sizes its delimiter and folds to one line |
| Report `Directory`/`Status` facts accepted line breaks and backticks | folded to one line, directory as `inlineCode` |

Remaining limits: redaction is pattern based; secrets with no recognisable shape or context are not found.

## Findings worth remembering

- Smart folding root cause: the policy only folded output that looked noisy; varied diagnostics scored about 0
Expand Down
86 changes: 78 additions & 8 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ 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 {DEFAULT_REPORT_OPTIONS, describeFindings, scanSensitive, type ReportFormat} from '../clipboard/report.js';
import {applyReportEdit, createReportReview, createTextReview, renderReportReview, reportReviewKey, type ReportReviewState} from '../ui/ReportReview.js';
import {configuredEditor} from '../host/HostActions.js';
import {comparePanelKey, createComparePanel, renderComparePanel, type ComparePanelState} from '../ui/ComparePanel.js';
import {buildComparisonReport, copyableDiff, previousRun} from '../output/compare.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 @@ -920,6 +922,8 @@ export class TerminalApp {
lostServiceConnection = false;
/** Commands the replay completed, for the reattach summary. */
private replayedCompletions = 0;
/** Commands that completed during the current replay: marked incomplete if the replay lost output. */
private replayedRecords: CompletedCommand[] = [];
private attachedSession?: AttachedSession;
private continuedJournal?: TranscriptSession;
private rendererEntered = false;
Expand Down Expand Up @@ -982,6 +986,10 @@ export class TerminalApp {
const completed = this.replayedCompletions;
if (completed > 0) parts.push(`${completed} command${completed === 1 ? '' : 's'} completed while detached`);
this.output.addFrontendInteraction('session', `${parts.join(' · ')}.`, INFO);
// The spool keeps command boundaries but drops output past its limit: any command completed during this replay
// may be missing some, and says so wherever its output is used.
if (summary.truncatedBytes > 0) for (const record of this.replayedRecords) record.outputIncomplete = true;
this.replayedRecords = [];
if (summary.truncatedBytes > 0) {
this.output.addFrontendInteraction('session',
`${formatBytes(summary.truncatedBytes)} of output produced while detached exceeded the retention limit and was not kept.`, ERROR);
Expand Down Expand Up @@ -1132,13 +1140,21 @@ export class TerminalApp {
() => this.output.addFrontendInteraction('/resume', 'Could not persist the current session; check local storage.', ERROR),
// Durable now: the service may forget these stream events.
saved => { if (saved.live) this.session.ack(saved.live.seq, saved.id); });
try {
if (this.continuedJournal) await this.journal.continue(this.continuedJournal);
else await this.journal.start();
// Initialize the journal before input can schedule snapshots, but do not wait
// for disk IO before restoring a fullscreen program's terminal and size.
// The journal serializes saves and acknowledges only durable checkpoints.
const journalStarted = (this.continuedJournal
? this.journal.continue(this.continuedJournal)
: this.journal.start()).then(() => {
this.journalActive = true;
} catch {
}, () => {
this.output.addFrontendInteraction('/resume', 'Continuous session journaling could not start; check local storage.', ERROR);
}
this.render();
});
// A fresh ordinary frontend must first publish its journal identity so an
// immediate detach can continue that journal. An already-owned terminal
// must instead restore its raw input and full size without waiting on IO.
if (!this.passthrough) await journalStarted;
this.startOnboarding();
// Restored terminal modes are a visible handoff: keys can arrive at once.
// Install raw input first so the host cannot echo or translate those keys.
Expand Down Expand Up @@ -1169,6 +1185,7 @@ export class TerminalApp {
this.presetFrontendReady = true;
if (this.presetShellReady) this.advancePresetStartup(0, this.shellCwd);
const exitCode = await this.done;
await journalStarted;
try { await this.journal.close(!this.detaching || this.shellEnded); } catch {
process.stderr.write('NMSh could not finish persisting the current presentation session.\n');
}
Expand Down Expand Up @@ -1411,6 +1428,26 @@ export class TerminalApp {
this.render();
return;
}
if (this.comparePanel) {
const {columns, rows} = this.dimensions();
const action = comparePanelKey(this.comparePanel, key, columns, rows - 4);
if (action?.kind === 'close') { this.comparePanel = undefined; this.returnFromPanel(); }
else if (action?.kind === 'copy' && action.what === 'diff') {
const panel = this.comparePanel;
const diff = copyableDiff(action.comparison);
// The plain diff is copied as is, without the report review: say so when it looks like it holds secrets.
const sensitive = scanSensitive(diff, homedir());
void this.copyText(diff, 'Copied diff', sensitive.length ? ` · not redacted: ${describeFindings(sensitive)} (r reviews)` : '').then(note => { if (note && this.comparePanel === panel) { panel.note = note; this.render(); } });
} else if (action?.kind === 'copy') {
// A comparison report is a report: the same review (exact text, wrapped, redaction) before it is copied.
this.comparePanel = undefined;
const comparison = structuredClone(action.comparison);
this.reportReview = createTextReview('Copy comparison report', options => buildComparisonReport(comparison, options), [comparison.a, comparison.b],
{...DEFAULT_REPORT_OPTIONS, home: homedir()}, Boolean(configuredEditor(process.env)));
}
this.render();
return;
}
if (this.reportReview) {
const {columns, rows} = this.dimensions();
const action = reportReviewKey(this.reportReview, key, rows - 4, columns);
Expand Down Expand Up @@ -2491,6 +2528,17 @@ export class TerminalApp {
this.render();
}
else if (slash.kind === 'copy') await this.copySlash(command, slash.args);
else if (slash.kind === 'compare') {
if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR);
else {
const recent = this.output.recentShellCommands();
const first = recent[(slash.first ?? 1) - 1];
const second = slash.second === undefined ? undefined : recent[slash.second - 1];
if (!first || (slash.second !== undefined && !second)) this.output.addFrontendInteraction(command.trim(), `No completed command output at ${!first ? slash.first ?? 1 : slash.second}.`, ERROR);
else if (second === first) this.output.addFrontendInteraction(command.trim(), 'Choose two different outputs to compare.', ERROR);
else this.openCompare(first, second ?? (slash.first === undefined ? previousRun(first, recent) : undefined));
}
}
else if (slash.kind === 'copySettings') { this.panelOrigin = undefined; this.rowPanel = createRowPanel('Copy', '/copy and /cp', ['copyMode', 'copyIncludeStatus', 'copyAutoExpand']); }
else if (slash.kind === 'appearance') { this.panelOrigin = undefined; await this.startAppearance(); }
else if (slash.kind === 'motion') {
Expand Down Expand Up @@ -2623,6 +2671,7 @@ export class TerminalApp {
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 === 'compare') this.openCompare(record);
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 @@ -2897,6 +2946,23 @@ export class TerminalApp {
finally { rmSync(directory, {recursive: true, force: true}); this.render(); }
}

/** Compare output…: straight to the difference when the other side is known, otherwise choose it (previous run first). */
private openCompare(base: CompletedCommand, partner?: CompletedCommand): void {
this.panelOrigin = undefined;
this.comparePanel = createComparePanel(base, this.output.recentShellCommands(), partner);
this.render();
}

/** A text copy that is not command output (a diff, a comparison report): the same tool and feedback. */
private async copyText(text: string, what: string, caution = ''): Promise<string | undefined> {
try { await writeClipboard(text); }
catch (error) { const note = clipboardFailure(error); this.noteClipboard(note, 'error'); return note; }
const stats = copyStats(text);
const note = `${what} · ${stats.lines.toLocaleString()} line${stats.lines === 1 ? '' : 's'} · ${stats.characters.toLocaleString()} characters${caution}`;
this.noteClipboard(note, 'success');
return note;
}

/** /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);
Expand Down Expand Up @@ -3379,6 +3445,7 @@ export class TerminalApp {
this.output.setActiveActivities(this.tapActivityObserver.finish(completedAt.getTime()));
// Never collapse output under the reader: while scrolled back or selecting, the block finishes expanded.
const completedRecord = this.output.complete(exitCode, {holdOpen: this.historyViewport.detached || Boolean(this.selection)});
if (this.replaying && completedRecord) this.replayedRecords.push(completedRecord);
if (completedRecord) {
completedRecord.startedAt = command.startedAt;
completedRecord.durationMs = Math.max(0, elapsed);
Expand Down Expand Up @@ -4142,7 +4209,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.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
return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.reportReview || this.comparePanel || 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 @@ -4179,6 +4246,7 @@ export class TerminalApp {
(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.comparePanel) return renderComparePanel(this.comparePanel, 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 +5099,8 @@ export class TerminalApp {
private copyPickerReport?: ReportFormat;
/** The review before a report is copied. */
private reportReview?: ReportReviewState;
/** Compare output…: choosing the other block, then the difference. */
private comparePanel?: ComparePanelState;
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
5 changes: 4 additions & 1 deletion src/ask/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ export const CONCEPTS: readonly Concept[] = [
{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', '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: 'compare', label: 'Compare output', support: 'actionable', open: '/compare', covers: ['/compare'],
aliases: ['compare output', 'compare outputs', 'diff output', 'compare runs', 'what changed between runs', 'compare with previous run'],
description: '/compare shows what changed between two command outputs (by default the latest and the previous run of the same command; /compare N M picks them by /copy number). Also Actions → Compare output…. Added, removed and changed lines; c copies the diff, r a comparison report.'},
{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 Expand Up @@ -343,7 +346,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [
{id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']},
{id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']},
{id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']},
{id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'transcripts']},
{id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'compare', 'transcripts']},
{id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'queue', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']},
{id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']},
{id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'],
Expand Down
Loading
Loading