diff --git a/CHANGELOG.md b/CHANGELOG.md index 6efdc7ed..e33080a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/edit`, a small native editor** for one text file ([#402](https://github.com/raiseCatError/notMyShell/issues/402)). `/edit notes.md` (or `e` in `/view`) opens the file in a panel: type, select with Shift, Ctrl+Z / Ctrl+Y undo and redo, Ctrl+F find, Ctrl+C / Ctrl+X copy and cut, Ctrl+S save, Esc close (it asks about unsaved changes). A path that does not exist yet is created on the first save. It is for quick edits, not an IDE: `vim`, `nano`, `micro` and your `$EDITOR` still run as usual. - Safe saving: the new text is written beside the file and renamed over it, keeping its mode, group, line endings, BOM and final newline; a symlink stays a symlink. If the file changed, was replaced or was deleted since you opened it, nothing is written until you choose Keep editing, Overwrite or Reload. A new file never replaces one that appeared meanwhile. - Limits: your own regular UTF-8 text files up to 1 MiB with one hard link, writable, with one kind of line ending. Others are refused with the reason and stay readable in `/view`. Extended attributes and ACLs are not carried over by a save. Unsaved text is lost if the NMSh window itself is closed. +- **Semantic Output** for test runs and builds ([#386](https://github.com/raiseCatError/notMyShell/issues/386), first phase). A completed block whose output NMSh recognizes gets one overview row with the counts the tool printed (`✔ node --test · 1,772 passed · 0 failed · 25 skipped`, `✘ TypeScript · 3 errors in 2 files`) and, when it listed failures or diagnostics, a details row that opens them, taken verbatim from the output. Recognized: Node's test runner (spec and TAP), Jest, Vitest, pytest, `cargo test`, `go test`, TypeScript, Rust and GCC/Clang diagnostics; anything else stays raw only, and short passing output gets nothing. Details start open when the raw output is folded; click the details row or use Actions → Show or hide details. The output itself, its folding, search, selection and plain `/copy` are unchanged. `/copy --semantic` copies the reading (raw for an output without one, as the note says) and `/copy --both` the raw output and the reading under labelled NMSh separators. Settings → Transcript → Semantic Output turns it off. ### Fixed diff --git a/docs/design/v021-terminal-intelligence.md b/docs/design/v021-terminal-intelligence.md index 5ab0b3be..860aaa04 100644 --- a/docs/design/v021-terminal-intelligence.md +++ b/docs/design/v021-terminal-intelligence.md @@ -121,7 +121,9 @@ completion from bounded input (first 2 MiB), so it exists before the block is dr `/copy` gains `--raw` (default and current behavior), `--semantic` (the overview and details as text, raw with a note when there are none) and `--both` (labeled raw then semantic), mutually exclusive, combinable with existing selectors. Settings → Transcript → Semantic Output: On (default) / Off. External providers (`jc`, `delta`) are not part of -this phase. +this phase, nor are a fully collapsed raw section (raw keeps its existing fold policy) and a separate semantic search +scope (derived rows are excluded from find and selection). Details open by default when the raw output is folded +(they are what the fold hides) and closed when it is expanded; the reader's toggle is kept per block. ### 5. `/track` (#393, reconciling draft PR #394) diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index e1358a5e..e644a606 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -18,7 +18,7 @@ Automated results here are not physical-terminal validation. | 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open | | 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | | 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | -| 4 | Semantic Output | #386 | | | | +| 4 | Semantic Output | #386 | `feature/v021-semantic` (stacked on slice 3) | | PR open | | 5 | `/track` | #393 (#394) | | | | | 6 | Modules and diagnostics | #364, #365 | | | | | 7 | Themes and controls | #401, #336 | | | | @@ -35,7 +35,8 @@ Automated results here are not physical-terminal validation. Checklist: [v0.21 physical QA](../testing/v021-physical-qa.md). Slice 1: section 1 (multi-row progress in Ghostty and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, -NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). +NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). Slice 4: section 4 (overview rows, details +toggle, copy flags, Off). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 8d03ff04..370770e2 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -20,6 +20,7 @@ | 2 `/view` | Terminal.app | macOS | zsh | | Not run | | | 3 `/edit` | Ghostty | macOS | zsh | | Not run | | | 3 `/edit` | Terminal.app | macOS | Bash or Fish | | Not run | | +| 4 Semantic Output | Ghostty | macOS | zsh | | Not run | | ## 1. Multi-row progress redraws (#388) @@ -69,3 +70,18 @@ (`chmod 444`) each explain why, and `/view` still opens them. - [ ] 3.8 `e` in `/view README.md` opens the editor on the same file; `vim notes.txt` and `nano notes.txt` still run as usual from the composer. + +## 4. Semantic Output + +- [ ] 4.1 `npm test` in this repository (long, passing): one overview row under the command (`✔ node --test · … passed · + 0 failed …`) above the folded output; nothing else changed; the lifecycle row appears once. +- [ ] 4.2 A failing run (break one test): the overview says how many failed and a details row lists the failing tests; + details are open when the output is folded, closed when it is expanded; clicking the row and Actions → Show or hide + details toggle it; Ctrl+O still folds only the raw output. +- [ ] 4.3 `npx tsc --noEmit` with an error, `cargo build` with an error, `make` of a C file with an error: a + diagnostics overview with `file:line:col` items. +- [ ] 4.4 `git status`, `ls`, `cat README.md`: no overview. +- [ ] 4.5 `/copy --semantic` and `/copy --both` on the failing run (paste into an editor); `/copy latest` is the raw + output only; find (Ctrl+F) never matches the overview text. +- [ ] 4.6 Settings → Transcript → Semantic Output Off: the rows disappear from every block; On brings them back. +- [ ] 4.7 `NO_COLOR=1` and Safe glyphs: the overview reads `+`/`x` and stays readable at 40 columns. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index a3915804..c3d84b49 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -61,7 +61,7 @@ import {anyBridgeTargetActive, targetsPinnedTo, type BridgeTargetId} from '../th import {applyThemeBridge, bridgeColorLevel, bridgeStateExists, detectTargets, fzfBridgeArgs, integrationHealth, reloadTmux, reportTargets, setupBat, targetPalette, themeBridgeKey, type ApplyOutcome, type BridgeContext, type TargetFacts, type TargetReport} from '../themeBridge/runtime.js'; import {createThemeBridgePanel, renderThemeBridgePanel, themeBridgeKey as themeBridgePanelKey, type BridgePanelAction, type BridgePanelContext, type ThemeBridgePanelState} from '../themeBridge/ThemeBridgePanel.js'; import {createRowPanel, renderRowPanel, rowPanelKey, type RowPanelState} from '../ui/RowPanel.js'; -import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection} from '../clipboard/copySelection.js'; +import {copiedNote, copySelectionPayload, parseCopyArgs, recordCopyText, resolveCopySelection, semanticCopyPayload, type CopyContent} from '../clipboard/copySelection.js'; import {captureOutputAnchor, createOutputSelection, moveOutputSelection, restoreOutputAnchor, revealOutputSelection, validateOutputSelection, type OutputAnchor, type OutputScope, type OutputSelection} from '../output/OutputSelection.js'; import {copyPickerKey, createCopyPicker, renderCopyPicker, type CopyPickerState} from '../ui/CopyPicker.js'; import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, scanSensitive, type ReportFormat} from '../clipboard/report.js'; @@ -939,6 +939,7 @@ export class TerminalApp { this.output.setTranscriptAppearance(this.promptConfiguration.transcript); this.output.presenter.setTreatment(this.promptConfiguration.presentation); this.output.setOutputFolding(this.promptConfiguration.outputFolding); + this.output.setSemanticOutput(this.promptConfiguration.semanticOutput); this.output.presenter.setLayout(this.promptConfiguration.transcriptPresentation); this.output.presenter.setHyperlinks(this.host.capabilities.hyperlinks); this.renderer.setCursorStyle(cursorStyleSequence(this.promptConfiguration.cursor.shape, this.promptConfiguration.cursor.blink)); @@ -2184,6 +2185,10 @@ export class TerminalApp { if (affordance.copy && x >= affordance.copy.column && x <= affordance.copy.end) { this.selection = undefined; void this.copyBlock(row.blockStartId); return; } if (x >= affordance.column) { this.selection = undefined; this.openBlockPalette(row.blockStartId); this.render(); return; } } + if (key.kind === 'mouseClick' && row.semanticToggle !== undefined) { + this.output.toggleSemanticDetails(row.semanticToggle); + this.render(); + } if (key.kind === 'mouseClick' && row.isFoldHint && row.commandIndex !== undefined) { this.output.toggleExpanded(row.commandIndex); this.render(); @@ -3037,7 +3042,7 @@ export class TerminalApp { category: 'Transcript', action: {kind: 'keepHistoryClip', id: history.id}}] : []; const composerItems: PaletteItem[] = this.editor.text.trim() && !isSlashInput(this.editor.text) ? [{id: 'clip:keep-composer', label: 'Keep composer draft as clip', detail: 'Keep text in memory without changing the draft or executing it', category: 'Transcript', action: {kind: 'keepComposerClip'}}] : []; - this.paletteState = createPalette([...historyItems, ...composerItems, ...paletteItems(), ...this.failureNavigationItems(), ...(record ? blockPaletteItems(record) : [])]); + this.paletteState = createPalette([...historyItems, ...composerItems, ...paletteItems(), ...this.failureNavigationItems(), ...(record ? blockPaletteItems(record, this.blockOptions(record)) : [])]); } /** Newest-first indexes of completed commands that did not exit 0 (a nonzero exit or an interrupt is authoritative; output text is not). */ @@ -3229,7 +3234,7 @@ export class TerminalApp { if (this.running || this.settingsPanelActive) return; const record = this.output.view().completed.find(item => item.startId === startId); if (record && !record.frontend) { - this.paletteState = createPalette(blockPaletteItems(record)); + this.paletteState = createPalette(blockPaletteItems(record, this.blockOptions(record))); this.blockPaletteContext = {palette: this.paletteState, scope: this.outputScope()}; if (this.outputNavigation?.intent === 'actions' && this.outputNavigation.focusedId === startId) { this.blockPaletteReturn = {selection: this.outputNavigation, palette: this.paletteState}; @@ -3242,7 +3247,7 @@ export class TerminalApp { const records = this.output.view().completed; const index = records.findIndex(item => item.startId === startId); const record = records[index]; - if (!record || !blockPaletteItems(record).some(item => item.action.kind === 'block' && item.action.id === action)) return; + if (!record || !blockPaletteItems(record, this.blockOptions(record)).some(item => item.action.kind === 'block' && item.action.id === action)) return; // Selection itself never runs actions. An explicit choice ends it before any editor/program handoff. this.endOutputNavigation(false); const includeStatus = this.promptConfiguration.copy.includeStatus; @@ -3260,6 +3265,7 @@ export class TerminalApp { catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); } } else if (action === 'fold') this.output.toggleExpanded(index); else if (action === 'view') this.openOutputViewer(record); + else if (action === 'details') this.output.toggleSemanticDetails(record.startId); else if (action === 'pager') await this.openBlockInPager(record); else if (action === 'explain') { this.explainBlock = record.startId; this.openAsk('why did this fail'); } else if (action === 'edit' || action === 'rerun') { @@ -3583,6 +3589,11 @@ export class TerminalApp { finally { rmSync(directory, {recursive: true, force: true}); this.render(); } } + /** Which conditional block actions this block offers. */ + private blockOptions(record: CompletedCommand): {details: boolean} { + return {details: Boolean(this.output.semanticFor(record)?.items.length)}; + } + private viewerLook(): ViewerLook { return {syntax: this.syntaxSgr, hyperlinks: this.host.capabilities.hyperlinks}; } @@ -3704,7 +3715,9 @@ export class TerminalApp { if (!this.outputNavigationAvailable()) return; const parsed = parseCopyArgs(args); if (!parsed.ok) { this.output.addFrontendInteraction(command.trim(), parsed.error, ERROR); return; } - const {selector, status, report} = parsed.request; + const {status, report, content} = parsed.request; + // --semantic and --both copy directly: without a selection they take the latest output, whatever the copy mode. + const selector = content === 'semantic' || content === 'both' ? (parsed.request.selector.kind === 'default' ? {kind: 'latest' as const, count: 1} : parsed.request.selector) : parsed.request.selector; const includeStatus = status ?? this.promptConfiguration.copy.includeStatus; if (selector.kind === 'picker' || (selector.kind === 'default' && this.promptConfiguration.copy.mode === 'picker')) { this.panelOrigin = undefined; @@ -3719,7 +3732,7 @@ export class TerminalApp { 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); + await this.copyRecords(resolved.records, includeStatus, undefined, parsed.request.content); } /** @@ -3727,9 +3740,11 @@ export class TerminalApp { * stored output, oldest first, each followed by its recorded completion status when asked. Nothing to copy leaves * the clipboard as it was. Feedback is a brief note above the composer, given only after the write succeeded. */ - private async copyRecords(records: readonly CompletedCommand[], includeStatus: boolean, before?: string): Promise { + private async copyRecords(records: readonly CompletedCommand[], includeStatus: boolean, before?: string, content: CopyContent = 'raw'): Promise { const scope = this.outputScope(); - const payload = [before ?? '', copySelectionPayload(records, includeStatus)].filter(Boolean).join('\n'); + // --semantic / --both: the reading as text (raw for a record without one, said in the note); raw is the default. + const semantic = content === 'raw' ? undefined : semanticCopyPayload(records, content, includeStatus, record => this.output.semanticFor(record)); + const payload = [before ?? '', semantic ? semantic.payload : copySelectionPayload(records, includeStatus)].filter(Boolean).join('\n'); if (!payload.trim()) { const what = records.length === 1 ? `${records[0]!.command.split('\n')[0]!.slice(0, 60)} printed no output` : 'the selected commands printed no output'; this.noteClipboard(`Nothing to copy: ${what}; the clipboard is unchanged`, 'info'); @@ -3743,7 +3758,8 @@ export class TerminalApp { const live = this.output.recentShellCommands(); this.output.expandBlocks(records.filter(record => live.includes(record) && recordCopyText(record, includeStatus)).map(record => record.startId)); } - this.noteClipboard(copiedNote(records.length, copyStats(payload), includeStatus), 'success'); + const fallback = semantic?.unavailable ? ` · ${semantic.unavailable === records.length ? 'semantic unavailable, copied raw' : `${semantic.unavailable} without a semantic reading copied raw`}` : ''; + this.noteClipboard(`${copiedNote(records.length, copyStats(payload), includeStatus)}${content === 'semantic' && !fallback ? ' · semantic' : content === 'both' && !fallback ? ' · raw and semantic' : ''}${fallback}`, 'success'); return true; } @@ -7374,6 +7390,7 @@ export class TerminalApp { this.output.setTranscriptAppearance(next.transcript); this.output.presenter.setTreatment(next.presentation); this.output.setOutputFolding(next.outputFolding); + this.output.setSemanticOutput(next.semanticOutput); this.output.presenter.setLayout(next.transcriptPresentation); if (this.settingsPanelState) this.settingsPanelState.glyphStyle = next.glyphStyle; return true; @@ -7619,6 +7636,7 @@ export class TerminalApp { if (next.welcome === 'none') this.welcomeGeneration++; this.output.setTranscriptAppearance(next.transcript); this.output.setOutputFolding(next.outputFolding); + this.output.setSemanticOutput(next.semanticOutput); this.output.presenter.setTreatment(next.presentation); this.output.presenter.setLayout(next.transcriptPresentation); this.transcriptPanelState = undefined; diff --git a/src/clipboard/copySelection.ts b/src/clipboard/copySelection.ts index 342e0cb5..2b6fcfa2 100644 --- a/src/clipboard/copySelection.ts +++ b/src/clipboard/copySelection.ts @@ -1,5 +1,6 @@ import {serializeCopyPayload, type CompletedCommand} from '../output/OutputBuffer.js'; import {parseReportFlag, type ReportFormat} from './report.js'; +import {semanticText, type SemanticResult} from '../output/semantic.js'; /** * Which completed commands /copy takes, by recency (1 is the latest), and whether NMSh's completion status follows @@ -16,6 +17,8 @@ import {parseReportFlag, type ReportFormat} from './report.js'; * - `--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). + * - `--raw` (the default), `--semantic` or `--both`: the stored output, the Semantic Output reading (raw for a record + * that has none), or both under labelled NMSh separators. One of them at most; never with --report. * `/cp` is the same command: it reaches this parser with the same arguments. */ export const COPY_MAX_INDEX = 999; @@ -34,22 +37,31 @@ export interface CopyRequest { status?: boolean; /** Copy as a report in this format instead of plain output. */ report?: ReportFormat; + /** What of each record is copied; undefined is raw (the stored output). */ + content?: CopyContent; } +export type CopyContent = 'raw' | 'semantic' | 'both'; + 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, --no-status or --report[=plain].'; +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, --report[=plain], or one of --raw, --semantic, --both.'; /** 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; + let content: CopyContent | 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') { + if (token === '--raw' || token === '--semantic' || token === '--both') { + const value = token.slice(2) as CopyContent; + if (content !== undefined && content !== value) return {ok: false, error: 'Use one of --raw, --semantic or --both.'}; + content = value; + } else 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.'}; status = value; @@ -58,10 +70,11 @@ export function parseCopyArgs(text: string): CopyArgs { } if (selectors.length > 1) return {ok: false, error: `One selection at a time (write lists with commas: /copy 1,3,5). ${USAGE}`}; 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} : {})}}); + if (report && content) return {ok: false, error: 'A report has its own format; leave out --raw, --semantic and --both.'}; + const withStatus = (selector: CopySelector): CopyArgs => ({ok: true, request: {selector, ...(status === undefined ? {} : {status}), ...(report ? {report} : {}), ...(content ? {content} : {})}}); const selector = selectors[0]; if (selector === undefined) return withStatus({kind: 'default'}); - if (selector === 'ui') return withStatus({kind: 'picker'}); + if (selector === 'ui') return content && content !== 'raw' ? {ok: false, error: '--semantic and --both copy directly: name the outputs (/copy 2 --semantic).'} : withStatus({kind: 'picker'}); if (selector === 'latest') return withStatus({kind: 'latest', count: 1}); const latest = /^-([1-9]\d*)$/u.exec(selector); if (latest) { @@ -130,6 +143,25 @@ export function copySelectionPayload(records: readonly CompletedCommand[], inclu .reduce((payload, text) => !payload ? text : payload.endsWith('\n') ? payload + text : `${payload}\n${text}`, ''); } +/** + * `/copy --semantic` and `--both`: per record, the Semantic Output reading as text, or (both) the raw output and the + * reading under labelled NMSh separators that cannot be mistaken for output. A record without a reading copies its raw + * output once, and is counted so the note can say so. Records keep their order and separation as in a raw copy. + */ +export function semanticCopyPayload(records: readonly CompletedCommand[], content: 'semantic' | 'both', includeStatus: boolean, + reading: (record: CompletedCommand) => SemanticResult | undefined): {payload: string; unavailable: number} { + let unavailable = 0; + const parts = records.map(record => { + const raw = recordCopyText(record, includeStatus); + const result = reading(record); + if (!result) { unavailable += 1; return raw; } + if (content === 'semantic') return semanticText(result); + return [`── NMSh · raw output ──`, raw.replace(/\r?\n$/u, ''), `── NMSh · Semantic Output ──`, semanticText(result)].join('\n'); + }).filter(Boolean); + const payload = parts.reduce((joined, text) => !joined ? text : joined.endsWith('\n') ? joined + text : `${joined}\n${text}`, ''); + return {payload, unavailable}; +} + /** The brief note after a successful copy: what was copied and how much (one output keeps the plain wording). */ export function copiedNote(count: number, stats: {characters: number; lines: number}, includeStatus: boolean): string { const what = count === 1 ? 'Copied' : `Copied ${count} outputs`; diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index c05244ea..76d2b91b 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -39,7 +39,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/action', insertion: '/action', alias: '/actions', description: 'Same as /actions, including /action N'}, {name: '/effects', insertion: '/effects ', description: 'Preview sparkles, rain or confetti in owned chrome; /effects stop cancels'}, {name: '/copy', insertion: '/copy', description: 'Select completed output: Up/Down, Enter copies, Esc returns (saved Quick Copy preferences still apply)'}, - {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output; -N the latest N, A-B or 1,3,5 several; --status adds completion status'}, + {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output; -N the latest N, A-B or 1,3,5 several; --status adds completion status; --semantic or --both the Semantic Output reading'}, {name: '/copy settings', insertion: '/copy settings', description: 'Output navigation, Quick Copy or Interactive Picker; completion status and auto-expand preferences'}, {name: '/view', insertion: '/view ', description: 'Read a file or a completed output in a native viewer: /view README.md · /view 2 (output 2, numbered as /copy) · /view (latest output)'}, {name: '/edit', insertion: '/edit ', description: 'Edit a small text file in NMSh: /edit notes.md (Ctrl+S saves safely, Esc closes; vim, nano and your editor still work as usual)'}, diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 5dff1188..7122f456 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -13,6 +13,7 @@ import {type PromptSnapshot} from '../prompt/snapshot.js'; import {type TranscriptAppearance} from '../prompt/configuration.js'; import {TranscriptPresenter, type TranscriptView} from './TranscriptPresenter.js'; import {queueSummaryFrom, SUMMARY_LIMIT, type QueueSummary} from '../session/QueueSummary.js'; +import {recognizeOutput, type SemanticResult} from './semantic.js'; export {renderHistoricalContext} from './TranscriptPresenter.js'; @@ -155,6 +156,10 @@ export class OutputBuffer { /** How rows look; presentation state here is never serialized into transcripts. */ readonly presenter = new TranscriptPresenter(); private outputFolding: OutputFoldingMode = 'smart'; + /** Semantic Output (#386): readings of completed output, derived and never stored; details open/closed per block. */ + private semanticOutput = true; + private readonly semanticReadings = new WeakMap(); + private readonly semanticDetails = new Map(); private readonly parser: AnsiOutputParser; private readonly completed: CompletedCommand[] = []; private readonly visualGaps = new Set(); @@ -196,6 +201,7 @@ export class OutputBuffer { private retireClearedBlocks(): void { this.completed.length = 0; this.userToggled.clear(); + this.semanticDetails.clear(); this.setOutputFilter(undefined); if (!this.active) return; this.active.start = 0; @@ -271,6 +277,7 @@ export class OutputBuffer { this.firstRun = undefined; this.parser.restore([]); this.completed.length = 0; + this.semanticDetails.clear(); this.visualGaps.clear(); this.lineTypes.clear(); this.historicalContexts.clear(); @@ -617,9 +624,45 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), + semantic: record => { + const result = this.semanticFor(record); + return result ? {result, open: this.semanticDetailsOpen(record, result)} : undefined; + }, }; } + setSemanticOutput(on: boolean): void { this.semanticOutput = on; } + + /** + * The block's Semantic Output reading, computed once per record from its stored output (never stored, so a resumed + * transcript reads it again lazily). Only shell commands with output are read; Off reads nothing. + */ + semanticFor(record: CompletedCommand): SemanticResult | undefined { + if (!this.semanticOutput || record.frontend || record.activities?.length || !record.output) return undefined; + let reading = this.semanticReadings.get(record); + if (reading === undefined) { + reading = recognizeOutput(record.command, record.output, record.exitCode) ?? null; + this.semanticReadings.set(record, reading); + } + // The usefulness gate: a short, passing output already reads well; a listing of failures is always worth it. + if (!reading || (reading.outcome === 'passed' && !reading.items.length && record.output.split('\n').length < 10)) return undefined; + return reading; + } + + /** Details start open when the raw output is folded (they are what it hides), closed when it is expanded. */ + semanticDetailsOpen(record: CompletedCommand, result = this.semanticFor(record)): boolean { + if (!result?.items.length) return false; + return this.semanticDetails.get(record.startId) ?? !record.expanded; + } + + toggleSemanticDetails(startId: number): boolean { + const record = this.completed.find(item => item.startId === startId); + const result = record && this.semanticFor(record); + if (!record || !result?.items.length) return false; + this.semanticDetails.set(startId, !this.semanticDetailsOpen(record, result)); + return true; + } + /** Important hidden lines of a collapsed block, by line id; computed on first draw and kept per block identity. */ private readonly highlightCache = new Map(); private foldHighlightsFor(record: CompletedCommand): readonly number[] { diff --git a/src/output/OutputSelection.ts b/src/output/OutputSelection.ts index da5f02f1..45501707 100644 --- a/src/output/OutputSelection.ts +++ b/src/output/OutputSelection.ts @@ -42,7 +42,7 @@ export interface OutputAnchor { offset: number; } -const rowKind = (row: WrappedRow) => row.isFoldHint ? 'fold' : row.isHistoricalHeader ? 'historical' : row.isFilterHint ? 'filter' : 'line'; +const rowKind = (row: WrappedRow) => row.isFoldHint || row.isSemantic ? 'fold' : row.isHistoricalHeader ? 'historical' : row.isFilterHint ? 'filter' : 'line'; const stableRow = (row: WrappedRow | undefined) => row !== undefined && ((row.lineIndex ?? -1) >= 0 || row.blockStartId !== undefined || row.activityId !== undefined); const matchesAnchor = (row: WrappedRow, anchor: OutputAnchor) => row.lineIndex === anchor.lineIndex && row.blockStartId === anchor.blockStartId && row.activityId === anchor.activityId && rowKind(row) === anchor.kind; diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index b4d4b6f2..d237d730 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -21,6 +21,8 @@ import {archiveColor, grayscaleArchiveColor} from '../prompt/snapshot.js'; import {isPromptRole, promptRoleColors} from '../prompt/prompt.js'; import {DEFAULT_TRANSCRIPT_APPEARANCE, normalizeConnectorFade, type GitColorMode, type TranscriptAppearance} from '../prompt/configuration.js'; import type {CompletedCommand, HistoricalContextSnapshot, SecondaryActivity} from './OutputBuffer.js'; +import {semanticRows} from './semanticRows.js'; +import type {SemanticResult} from './semantic.js'; /** * History rules are UI chrome, resolved at use so themes apply. The shipped @@ -77,6 +79,8 @@ export interface TranscriptView { ownerOf: (lineIndex: number) => number | undefined; /** Line ids worth keeping in view inside a collapsed block's hidden middle (failures, diagnostics). */ foldHighlights?: (record: CompletedCommand) => readonly number[]; + /** The block's Semantic Output reading and whether its details are open (#386); undefined: raw only. */ + semantic?: (record: CompletedCommand) => {result: SemanticResult; open: boolean} | undefined; } /** Transient interaction state the app tracks; the presenter decides how it looks. */ @@ -181,6 +185,9 @@ export class TranscriptPresenter { } const cmd = completed.find(c => c.outputStartId === i); + // Semantic Output: the reading's rows come first in the block's output area; the output below is unchanged. + const reading = cmd && cmd.endId !== undefined && cmd.endId > cmd.outputStartId ? view.semantic?.(cmd) : undefined; + if (cmd && reading) result.push(...semanticRows(reading.result, reading.open, width, {lineIndex: cmd.outputStartId, commandIndex: completed.indexOf(cmd), startId: cmd.startId})); if (cmd && cmd.endId !== undefined && cmd.endId > cmd.outputStartId) { const hiddenLines = cmd.endId - cmd.outputStartId; // Activity-bearing parents use their lifecycle row as the disclosure control below. diff --git a/src/output/TranscriptSearch.ts b/src/output/TranscriptSearch.ts index 58356794..a1b5d0fd 100644 --- a/src/output/TranscriptSearch.ts +++ b/src/output/TranscriptSearch.ts @@ -78,7 +78,7 @@ export function logicalLines(rows: readonly WrappedRow[], include: (row: Wrapped let current: LogicalLine | undefined; let currentIndex: number | undefined; rows.forEach((row, index) => { - if (row.isFilterHint || !include(row)) { current = undefined; currentIndex = undefined; return; } + if (row.isFilterHint || row.isSemantic || !include(row)) { current = undefined; currentIndex = undefined; return; } if (current && row.lineIndex !== undefined && row.lineIndex === currentIndex) { current.offsets.push(current.text.length); current.rows.push(index); diff --git a/src/output/TranscriptSelection.ts b/src/output/TranscriptSelection.ts index c6fc39fe..fb8708a4 100644 --- a/src/output/TranscriptSelection.ts +++ b/src/output/TranscriptSelection.ts @@ -52,7 +52,7 @@ export function selectedText(rows: readonly WrappedRow[], selection: TranscriptS let previousLine: number | undefined; for (let index = start; index <= end && index < rows.length; index += 1) { const row = rows[index]!; - if (row.isFilterHint) continue; + if (row.isFilterHint || row.isSemantic) continue; const text = row.plain.slice(row.indent ?? 0); // Continuation rows of the same logical line are joined exactly; padding is trimmed once the line ends. if (row.lineIndex !== undefined && row.lineIndex === previousLine && lines.length) lines[lines.length - 1] += text; diff --git a/src/output/semantic.ts b/src/output/semantic.ts new file mode 100644 index 00000000..6d707f91 --- /dev/null +++ b/src/output/semantic.ts @@ -0,0 +1,293 @@ +/** + * Semantic Output (#386, first phase): a deterministic reading of a completed command's stored output, never a + * replacement for it. Each recognizer is a pure function of (command, output, exit code) that accepts only its own + * unmistakable evidence (a runner's own summary line, a compiler's own diagnostic format) and fails closed otherwise. + * Counts are the ones the tool printed, items are lines taken verbatim from the output, and nothing is inferred + * about state the output does not show. The raw output stays authoritative: this is presentation and an optional copy. + */ + +export interface SemanticItem { + /** The line as the tool printed it (trimmed), possibly joined with its location line. */ + text: string; + tone: 'failure' | 'warning'; +} + +export interface SemanticResult { + kind: 'tests' | 'diagnostics'; + /** Who produced the facts ("node --test", "pytest", "TypeScript"). */ + source: string; + outcome: 'passed' | 'failed'; + /** One line of facts, as printed ("1,797 passed · 2 failed · 25 skipped"). */ + overview: string; + /** Failing tests or diagnostics, at most MAX_ITEMS. */ + items: SemanticItem[]; + /** Items found beyond MAX_ITEMS. */ + omitted: number; + /** False when only part of a very large output was read. */ + complete: boolean; +} + +export const MAX_ITEMS = 20; +/** Output read for recognition: the end of the output, where runners and compilers summarize. */ +export const MAX_SEMANTIC_BYTES = 2 * 1024 * 1024; + +/** The last line that matches (runners print their summary at the end). */ +function findLast(lines: readonly string[], test: (line: string) => boolean): string | undefined { + for (let index = lines.length - 1; index >= 0; index -= 1) if (test(lines[index]!)) return lines[index]; + return undefined; +} + +const number = (value: string | undefined) => value === undefined ? undefined : Number(value.replace(/,/gu, '')); +const count = (value: number) => value.toLocaleString('en-US'); +const plural = (value: number, word: string) => `${count(value)} ${word}${value === 1 ? '' : 's'}`; + +function facts(parts: ReadonlyArray): string { + return parts.filter(([, value]) => value !== undefined).map(([label, value]) => `${count(value!)} ${label}`).join(' · '); +} + +/** + * Lines longer than this are not read: runner summaries and diagnostics are short, and every pattern below then runs + * in time linear in what it reads, whatever a hostile output contains. + */ +export const MAX_LINE = 2048; + +/** Items from lines `parse` accepts (it returns the item text), deduplicated and bounded. */ +function collect(lines: readonly string[], parse: (line: string) => string | undefined, tone: SemanticItem['tone'] = 'failure'): {items: SemanticItem[]; omitted: number} { + const seen = new Set(); + const items: SemanticItem[] = []; + let omitted = 0; + for (const line of lines) { + const text = parse(line)?.trim(); + if (!text || seen.has(text)) continue; + seen.add(text); + if (items.length < MAX_ITEMS) items.push({text, tone}); else omitted += 1; + } + return {items, omitted}; +} + +/** `N word` counts in one summary line (only ever applied to the one line a recognizer picked). */ +const countIn = (line: string, word: string) => number(new RegExp(`(?:^|[^\\d,])(\\d{1,3}(?:,\\d{3})*|\\d{1,12}) ${word}\\b`, 'u').exec(line)?.[1]); + +/** `path:line:col` with a path that has no spaces; end-anchored with bounded digits, so linear. */ +const LOCATION_TAIL = /:\d{1,7}:\d{1,7}$/u; +const isLocation = (text: string) => text.length > 0 && !/\s/u.test(text) && LOCATION_TAIL.test(text); + +type Recognizer = (command: string, lines: readonly string[], exitCode: number) => Omit | undefined; + +/** Node's test runner: the spec reporter's `ℹ` summary, or TAP's `#` summary. */ +const nodeTest: Recognizer = (_command, lines) => { + for (const marker of ['ℹ', '#'] as const) { + const value = (name: string) => { + const prefix = `${marker} ${name} `; + const rest = findLast(lines, line => line.startsWith(prefix))?.slice(prefix.length).trim(); + return rest !== undefined && /^\d{1,12}$/u.test(rest) ? Number(rest) : undefined; + }; + const tests = value('tests'); + const pass = value('pass'); + const fail = value('fail'); + if (tests === undefined || pass === undefined || fail === undefined) continue; + const skipped = value('skipped'); + const todo = value('todo'); + const cancelled = value('cancelled'); + const section = lines.indexOf('✖ failing tests:'); + const failing = marker === 'ℹ' + ? collect(section >= 0 ? lines.slice(section + 1) : lines, line => line.startsWith('✖ ') && line !== '✖ failing tests:' ? line.slice(2).replace(/ \([\d.]{1,20}m?s\)$/u, '') : undefined) + : collect(lines, line => { + const head = /^not ok \d{1,9} - /u.exec(line); + if (!head) return undefined; + const text = line.slice(head[0].length); + const hash = text.indexOf(' #'); + // A TODO or SKIP directive is not a failure. + if (hash >= 0 && /^\s*(?:todo|skip)\b/iu.test(text.slice(hash + 2))) return undefined; + return hash >= 0 ? text.slice(0, hash) : text; + }); + return {kind: 'tests', source: 'node --test', outcome: fail > 0 || (cancelled ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', pass], ['failed', fail], ['skipped', skipped || undefined], ['todo', todo || undefined], ['cancelled', cancelled || undefined]]), ...failing}; + } + return undefined; +}; + +/** Jest's `Tests: … N total` line (and its `●` failure headings). */ +const jest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => line.startsWith('Tests:') && line.endsWith(' total') && /\d$/u.test(line.slice(0, -6))); + if (!summary) return undefined; + const failed = countIn(summary, 'failed'); + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('● ') && !text.startsWith('● Console') ? text.slice(2) : undefined; + }); + return {kind: 'tests', source: 'Jest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['skipped', countIn(summary, 'skipped')], ['todo', countIn(summary, 'todo')]]), ...failing}; +}; + +/** Vitest's `Tests 2 failed | 40 passed (42)` line and its FAIL lines. */ +const vitest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => line.startsWith(' ') && line.trimStart().startsWith('Tests ') && /\(\d{1,9}\)$/u.test(line)); + if (!summary || !lines.some(line => line.trimStart().startsWith('Test Files '))) return undefined; + const failed = countIn(summary, 'failed'); + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('FAIL ') ? text.slice(5).trimStart() : undefined; + }); + return {kind: 'tests', source: 'Vitest', outcome: (failed ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['skipped', countIn(summary, 'skipped')], ['todo', countIn(summary, 'todo')]]), ...failing}; +}; + +/** pytest's closing `=== 1 failed, 3 passed in 0.12s ===` line and its short summary. */ +const pytest: Recognizer = (_command, lines) => { + const summary = findLast(lines, line => { + if (!line.startsWith('=') || !line.endsWith('=')) return false; + const inner = line.replace(/^=+ /u, '').replace(/ =+$/u, ''); + const at = inner.lastIndexOf(' in '); + if (at < 0 || !/^[\d.]{1,12}s(?: \([\d:]{1,12}\))?$/u.test(inner.slice(at + 4))) return false; + return inner.slice(0, at).split(', ').every(part => /^\d{1,9} \w{1,20}$/u.test(part)); + }); + if (!summary) return undefined; + const failed = countIn(summary, 'failed'); + const errors = countIn(summary, 'errors?'); + const failing = collect(lines, line => { + const head = line.startsWith('FAILED ') ? 7 : line.startsWith('ERROR ') ? 6 : 0; + return head ? line.slice(head) : undefined; + }); + return {kind: 'tests', source: 'pytest', outcome: (failed ?? 0) + (errors ?? 0) > 0 ? 'failed' : 'passed', + overview: facts([['passed', countIn(summary, 'passed') ?? 0], ['failed', failed ?? 0], ['errors', errors], ['skipped', countIn(summary, 'skipped')], ['xfailed', countIn(summary, 'xfailed')]]), ...failing}; +}; + +/** cargo test's `test result:` lines (one per test binary; summed). */ +const cargoTest: Recognizer = (_command, lines) => { + const results = lines.map(line => /^test result: (?:ok|FAILED)\. (\d{1,9}) passed; (\d{1,9}) failed; (\d{1,9}) ignored;/u.exec(line)).filter(match => match !== null); + if (!results.length) return undefined; + const sum = (index: number) => results.reduce((total, match) => total + Number(match[index]), 0); + const failing = collect(lines, line => line.startsWith('test ') && line.endsWith(' ... FAILED') ? line.slice(5, -11) : undefined); + return {kind: 'tests', source: 'cargo test', outcome: sum(2) > 0 ? 'failed' : 'passed', + overview: facts([['passed', sum(1)], ['failed', sum(2)], ['ignored', sum(3) || undefined]]), ...failing}; +}; + +/** go test's per-package `ok` / `FAIL` lines and `--- FAIL:` tests. */ +const goTest: Recognizer = (command, lines) => { + if (!/^\s*(?:\S+\/)?go\s+test\b/u.test(command)) return undefined; + const ok = lines.filter(line => /^ok\s+\S+\s+(?:[\d.]+s|\(cached\))/u.test(line)).length; + const failedPackages = lines.filter(line => /^FAIL\s+\S+\s+(?:[\d.]+s|\[build failed\]|\[setup failed\])/u.test(line)).length; + if (!ok && !failedPackages) return undefined; + const failing = collect(lines, line => { + const text = line.trimStart(); + return text.startsWith('--- FAIL: ') ? text.slice(10).split(' ')[0] : undefined; + }); + return {kind: 'tests', source: 'go test', outcome: failedPackages > 0 ? 'failed' : 'passed', + overview: `${plural(ok, 'package')} ok · ${plural(failedPackages, 'package')} failed${failing.items.length ? ` · ${plural(failing.items.length + failing.omitted, 'failing test')}` : ''}`, ...failing}; +}; + +/** Commands that compile or check code: their diagnostics are the point even when they exit 0. */ +const BUILD = /^\s*(?:\S+\/)?(?:tsc|npx\s+tsc|vue-tsc|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|make|ninja|cmake|cargo\s+(?:build|check|clippy|run|test)|rustc|swift\s+build|xcodebuild|go\s+(?:build|vet)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|typecheck|check|lint|compile|tsc))\b/u; + +/** One TypeScript diagnostic, pretty (`file:1:2 - error TS…: …`) or plain (`file(1,2): error TS…: …`). */ +function typescriptDiagnostic(line: string): string | undefined { + for (const severity of ['error', 'warning'] as const) { + const pretty = line.indexOf(` - ${severity} TS`); + if (pretty > 0 && isLocation(line.slice(0, pretty))) { + const rest = /^(TS\d{1,6}): (.*)$/u.exec(line.slice(pretty + severity.length + 4)); + if (rest) return `${line.slice(0, pretty)} ${rest[1]}: ${rest[2]}`; + } + const plain = line.indexOf(`): ${severity} TS`); + if (plain > 0) { + const head = /^(\S+)\((\d{1,7}),(\d{1,7})$/u.exec(line.slice(0, plain)); + const rest = /^(TS\d{1,6}): (.*)$/u.exec(line.slice(plain + severity.length + 4)); + if (head && rest) return `${head[1]}:${head[2]}:${head[3]} ${rest[1]}: ${rest[2]}`; + } + } + return undefined; +} + +/** TypeScript diagnostics, and tsc's `Found N errors` line. */ +const typescript: Recognizer = (command, lines, exitCode) => { + const found = findLast(lines, line => /^Found \d{1,9} errors?(?: in .+)?\.?$/u.test(line)); + if (!found && !lines.some(line => typescriptDiagnostic(line) !== undefined)) return undefined; + if (!found && exitCode === 0 && !BUILD.test(command)) return undefined; + const items = collect(lines, typescriptDiagnostic); + const errors = number(found ? /^Found (\d{1,9}) errors?/u.exec(found)?.[1] : undefined) ?? items.items.length + items.omitted; + const files = found ? /in (\d{1,9}) files/u.exec(found)?.[1] : undefined; + return {kind: 'diagnostics', source: 'TypeScript', outcome: errors > 0 ? 'failed' : 'passed', + overview: `${plural(errors, 'error')}${files ? ` in ${files} files` : ''}`, ...items}; +}; + +/** The location of a Rust ` --> file:line:col` line, or undefined. */ +function rustLocation(line: string | undefined): string | undefined { + const text = line?.trim(); + return text?.startsWith('--> ') && isLocation(text.slice(4)) ? text.slice(4) : undefined; +} + +/** Rust: `error[E0308]: …` / `warning: …` headings with their ` --> file:line:col` location. */ +const rust: Recognizer = (command, lines, exitCode) => { + if (!lines.some(line => rustLocation(line) !== undefined)) return undefined; + if (exitCode === 0 && !BUILD.test(command)) return undefined; + const items: SemanticItem[] = []; + let omitted = 0; + let errors = 0; + let warnings = 0; + for (let index = 0; index < lines.length; index += 1) { + const heading = /^(error|warning)(\[E\d{4}\])?: (.+)$/u.exec(lines[index]!); + if (!heading || /^(?:could not compile|aborting due to|`.+` \(.+\) generated \d+ warnings?)/u.test(heading[3]!)) continue; + const location = rustLocation(lines[index + 1]); + if (!location) continue; + if (heading[1] === 'error') errors += 1; else warnings += 1; + const text = `${location} ${heading[1]}${heading[2] ?? ''}: ${heading[3]}`; + if (items.length < MAX_ITEMS) items.push({text, tone: heading[1] === 'error' ? 'failure' : 'warning'}); else omitted += 1; + } + if (!errors && !warnings) return undefined; + return {kind: 'diagnostics', source: 'Rust', outcome: errors > 0 ? 'failed' : 'passed', + overview: [errors ? plural(errors, 'error') : '', warnings ? plural(warnings, 'warning') : ''].filter(Boolean).join(' · '), items, omitted}; +} + +/** One GCC/Clang diagnostic `file:line:col: error: …` as [location, severity, message]. */ +function cDiagnostic(line: string): [string, string, string] | undefined { + for (const severity of ['fatal error', 'error', 'warning'] as const) { + const at = line.indexOf(`: ${severity}: `); + if (at > 0 && isLocation(line.slice(0, at))) return [line.slice(0, at), severity, line.slice(at + severity.length + 4)]; + } + return undefined; +} + +/** GCC and Clang diagnostics. */ +const cCompilers: Recognizer = (command, lines, exitCode) => { + if (!lines.some(line => cDiagnostic(line) !== undefined)) return undefined; + if (exitCode === 0 && !BUILD.test(command)) return undefined; + let errors = 0; + let warnings = 0; + for (const line of lines) { + const diagnostic = cDiagnostic(line); + if (diagnostic) { if (diagnostic[1] === 'warning') warnings += 1; else errors += 1; } + } + const failures = collect(lines, line => { + const diagnostic = cDiagnostic(line); + return diagnostic && `${diagnostic[0]} ${diagnostic[1]}: ${diagnostic[2]}`; + }); + for (const item of failures.items) if (/ warning: /u.test(item.text)) item.tone = 'warning'; + return {kind: 'diagnostics', source: 'GCC/Clang', outcome: errors > 0 ? 'failed' : 'passed', + overview: [errors ? plural(errors, 'error') : '', warnings ? plural(warnings, 'warning') : ''].filter(Boolean).join(' · '), ...failures}; +}; + +/** Most specific first: a runner's summary wins over diagnostics that happen to appear in its output. */ +const RECOGNIZERS: readonly Recognizer[] = [nodeTest, jest, vitest, pytest, cargoTest, goTest, typescript, rust, cCompilers]; + +/** The semantic reading of a completed output, or undefined when no recognizer is sure. */ +export function recognizeOutput(command: string, output: string, exitCode: number): SemanticResult | undefined { + if (!output) return undefined; + const complete = output.length <= MAX_SEMANTIC_BYTES; + const text = complete ? output : output.slice(-MAX_SEMANTIC_BYTES); + // trimEnd is linear (a `\s+$` pattern is not), and over-long lines are not read at all. + const lines = text.split('\n').map(line => line.length > MAX_LINE ? '' : line.trimEnd()); + const shown = command.length > 1024 ? command.slice(0, 1024) : command; + for (const recognizer of RECOGNIZERS) { + const result = recognizer(shown, lines, exitCode); + if (result) return {...result, complete}; + } + return undefined; +} + +/** Plain text of a reading, for `/copy --semantic` and `--both`: everything, whatever is folded on screen. */ +export function semanticText(result: SemanticResult): string { + const lines = [`${result.source}: ${result.overview}`, ...result.items.map(item => item.text)]; + if (result.omitted) lines.push(`… ${count(result.omitted)} more in the raw output`); + if (!result.complete) lines.push('(read from the last 2 MiB of a longer output)'); + return lines.join('\n'); +} diff --git a/src/output/semanticRows.ts b/src/output/semanticRows.ts new file mode 100644 index 00000000..bc8f085e --- /dev/null +++ b/src/output/semanticRows.ts @@ -0,0 +1,47 @@ +import {colorLevel} from '../presentation/capabilities.js'; +import {GLYPHS, getCurrentGlyphMode} from '../ui/glyphs.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateText} from '../util/text.js'; +import type {SemanticResult} from './semantic.js'; +import type {WrappedRow} from './viewport.js'; + +/** + * How a Semantic Output reading is drawn inside its block (#386): one overview row, then, when the reading lists + * failures or diagnostics, a disclosure row and (open) the items. The rows are derived presentation: never stored + * transcript lines, never in raw search, selection or /copy (which copy the output as it was). + */ + +const RESET = '\u001b[0m'; +const inert = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, '·'); + +function itemLabel(result: SemanticResult): string { + const total = result.items.length + result.omitted; + if (result.kind === 'tests') return `${total.toLocaleString('en-US')} failing test${total === 1 ? '' : 's'}`; + return `${total.toLocaleString('en-US')} diagnostic${total === 1 ? '' : 's'}`; +} + +export function semanticRows(result: SemanticResult, open: boolean, width: number, owner: {lineIndex: number; commandIndex: number; startId: number}): WrappedRow[] { + const color = colorLevel() !== 'none'; + const paint = (role: keyof typeof UI_COLORS) => color ? foreground(UI_COLORS[role]) : ''; + const passed = result.outcome === 'passed'; + const icon = passed ? GLYPHS.success : GLYPHS.failure; + const base = {lineIndex: owner.lineIndex, commandIndex: owner.commandIndex, isSemantic: true as const}; + const overviewPlain = truncateText(` ${icon} ${result.source} · ${result.overview}${result.complete ? '' : ' · from the end of the output'}`, width); + const rows: WrappedRow[] = [{...base, plain: overviewPlain, + ansi: `${paint(passed ? 'success' : 'failure')}${overviewPlain.slice(0, 2 + icon.length + 1)}${RESET}${paint('secondary')}${overviewPlain.slice(2 + icon.length + 1)}${RESET}`}]; + if (!result.items.length) return rows; + const marker = open ? (getCurrentGlyphMode() === 'nerd' ? '⌄' : 'v') : GLYPHS.selection; + const disclosure = truncateText(` ${marker} ${itemLabel(result)}${open ? '' : ' · click or Actions to show'}`, width); + rows.push({...base, plain: disclosure, ansi: `${paint('subtle')}${disclosure}${RESET}`, semanticToggle: owner.startId}); + if (!open) return rows; + for (const item of result.items) { + const mark = item.tone === 'failure' ? GLYPHS.failure : '!'; + const plain = truncateText(` ${mark} ${inert(item.text)}`, width); + rows.push({...base, plain, ansi: `${paint(item.tone === 'failure' ? 'failure' : 'warning')}${plain.slice(0, 4 + mark.length)}${RESET}${plain.slice(4 + mark.length)}`}); + } + if (result.omitted) { + const plain = truncateText(` … ${result.omitted.toLocaleString('en-US')} more in the output below`, width); + rows.push({...base, plain, ansi: `${paint('subtle')}${plain}${RESET}`}); + } + return rows; +} diff --git a/src/output/viewport.ts b/src/output/viewport.ts index 1dabbbab..0cc72b4a 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -20,6 +20,10 @@ export interface WrappedRow { isFilterHint?: boolean; /** A row of the first-run welcome (0 is Vespyr's block above it), so the ending can reveal it line by line. */ firstRunLine?: number; + /** A Semantic Output row: derived presentation, never transcript content (skipped by search, selection and copy). */ + isSemantic?: boolean; + /** Clicking this row opens or closes the Semantic Output details of the block with this startId. */ + semanticToggle?: number; } export interface StickyHeader { diff --git a/src/prompt/configuration.ts b/src/prompt/configuration.ts index 7c174942..9fe28ccf 100644 --- a/src/prompt/configuration.ts +++ b/src/prompt/configuration.ts @@ -619,6 +619,8 @@ export interface PromptConfiguration { liveSessionMultiple: LiveSessionMultiple; /** Whether long, boring finished output starts collapsed. Presentation only. */ outputFolding: OutputFoldingMode; + /** Semantic Output: a test or build summary drawn in recognized blocks (#386). Presentation only; Off draws raw only. */ + semanticOutput: boolean; /** What new presentation sessions show at the top; archived sessions keep theirs. */ welcome: WelcomeProviderId; /** Ghost-text suggestion provider; external providers fall back to Native. */ @@ -865,6 +867,7 @@ export const DEFAULT_PROMPT_CONFIGURATION: PromptConfiguration = { liveSessionMultiple: 'ask', notifications: {...DEFAULT_NOTIFICATION_SETTINGS}, outputFolding: 'smart', + semanticOutput: true, welcome: 'vespyr', suggestions: 'nmsh', history: 'native', @@ -986,6 +989,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio ? value.liveSessionMultiple as LiveSessionMultiple : 'ask'; // Off persists as `never`, so v0.4 configs load unchanged. const outputFolding: OutputFoldingMode = value.outputFolding === 'never' || value.outputFolding === 'always' ? value.outputFolding : 'smart'; + const semanticOutput = value.semanticOutput !== false; const welcome: WelcomeProviderId = WELCOME_PROVIDER_IDS.includes(value.welcome as WelcomeProviderId) ? value.welcome as WelcomeProviderId : 'vespyr'; const suggestions: SuggestionProviderId = SUGGESTION_PROVIDER_IDS.includes(value.suggestions as SuggestionProviderId) @@ -1069,7 +1073,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio if (!Array.isArray(value.modules)) { return {...structuredClone(DEFAULT_PROMPT_CONFIGURATION), provider, onboardingComplete: value.onboardingComplete === true, - toolsSetupComplete, glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, + toolsSetupComplete, glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, starship: {configPath: starshipConfigPath}, powerlevel10k, ohMyPosh, transcript, syntax, notifications, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, spacing, gap, separator, ...themed}; } @@ -1129,7 +1133,7 @@ export function normalizePromptConfiguration(value: unknown): PromptConfiguratio return {provider, onboardingComplete: value.onboardingComplete === true, toolsSetupComplete, - glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, + glyphStyle, glyphChoiceComplete, onboardingBackdrop, sessionRetention, updateMode, updateFrequency, liveSessionStartup, liveSessionMultiple, outputFolding, semanticOutput, welcome, suggestions, history, picker, navigation, suggestionsOnEmpty, presentation, nmsh, transcript, syntax, notifications, powerlevel10k, ohMyPosh, starship: {configPath: starshipConfigPath}, placement, composerLayout, composerPosition, panelPosition, transcriptPresentation, composerDividers: value.composerDividers !== false, modules, separator, spacing, gap, ...themed}; } diff --git a/src/setup/SetupCat.ts b/src/setup/SetupCat.ts index 348a2e88..6b876774 100644 --- a/src/setup/SetupCat.ts +++ b/src/setup/SetupCat.ts @@ -369,6 +369,7 @@ export const SETUP_SECTIONS: readonly SetupSection[] = [ {row: configRow('dividerDensity')}, {row: configRow('dividerColors'), note: () => 'Live composer dividers follow Divider lines (Look, under Chroma); past dividers never move'}, {row: configRow('outputFolding')}, + {row: configRow('semanticOutput')}, ]}, {id: 'history', title: 'History & navigation', intro: ['NMSh Native covers history, directory jumps and picking.', 'External providers are optional alternatives.'], rows: [ providerRow('setupHistory', 'History', 'Command history source', 'History', HISTORY_PROVIDERS, diff --git a/src/ui/BlockActions.ts b/src/ui/BlockActions.ts index 8ec19081..a6e7bba4 100644 --- a/src/ui/BlockActions.ts +++ b/src/ui/BlockActions.ts @@ -5,7 +5,7 @@ import {displayWidth} from '../util/text.js'; import {recordCopyText} from '../clipboard/copySelection.js'; import {findOutputReferences} from '../output/references.js'; -export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'view' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; +export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'view' | 'details' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; /** One registry for both pointer-opened and keyboard-opened block palettes. */ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: string}[] = [ {id: 'copyCommand', label: 'Copy command', detail: 'Stored plain command'}, @@ -13,6 +13,7 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {id: 'copyBoth', label: 'Copy command + output', detail: 'Stored plain command and full output'}, {id: 'copyReport', label: 'Copy as report…', detail: 'Command, status, exit code, duration and output as Markdown or plain text; reviewed first'}, {id: 'compare', label: 'Compare output…', detail: 'With the previous run of this command, or another block you choose'}, + {id: 'details', label: 'Show or hide details', detail: 'The failing tests or diagnostics Semantic Output read from this output; the output is unchanged'}, {id: 'view', label: 'View output', detail: "Full stored output in NMSh's viewer: find, line numbers, wrap; read-only"}, {id: 'pager', label: 'Open in pager', detail: 'Command and full stored output in your pager (less), read-only'}, {id: 'rerun', label: 'Rerun command', detail: 'Explicitly submit a visible command in the current shell directory'}, @@ -25,10 +26,11 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {id: 'explain', label: 'Explain failure', detail: "Why it failed, from this block's own output (read-only)"}, ]; -export function blockPaletteItems(record: CompletedCommand): PaletteItem[] { - // Explain failure is offered only on failed blocks, and references only when the output has some. +export function blockPaletteItems(record: CompletedCommand, options: {details?: boolean} = {}): PaletteItem[] { + // Explain failure is offered only on failed blocks, references only when the output has some, and details only when + // Semantic Output listed failures or diagnostics for this block. const hasReferences = findOutputReferences(record).length > 0; - return BLOCK_ACTIONS.filter(action => (action.id !== 'explain' || (record.exitCode ?? 0) !== 0) && (action.id !== 'references' || hasReferences)).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, + return BLOCK_ACTIONS.filter(action => (action.id !== 'explain' || (record.exitCode ?? 0) !== 0) && (action.id !== 'references' || hasReferences) && (action.id !== 'details' || options.details)).map(action => ({id: `block:${record.startId}:${action.id}`, label: action.label, detail: action.detail, category: 'Transcript', action: {kind: 'block', id: action.id, startId: record.startId}})); } diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 035aac0a..4a551602 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -411,6 +411,8 @@ export const SETTINGS_ROWS: readonly SettingsRow[] = [ enumRow({id: 'outputFolding', level: 'advanced', label: 'Output folding', description: 'Off, Smart (long repetitive successes), or Always (every long block)', category: 'Transcript', values: OUTPUT_FOLDING_MODES, labels: ['Off', 'Smart', 'Always'], get: config => config.outputFolding, set: (config, outputFolding) => ({...config, outputFolding})}), + {id: 'semanticOutput', label: 'Semantic Output', description: 'A one-line test or build summary (counts as the tool printed them) and the failures or diagnostics it listed, in recognized blocks. The output itself is unchanged; Off shows raw output only', + category: 'Transcript', control: 'boolean', get: config => config.semanticOutput, set: (config, semanticOutput) => ({...config, semanticOutput})}, enumRow({id: 'updateMode', label: 'Automatic updates', description: 'Automatic prepares verified releases for the next launch (official source installs only); Notify only announces them; Off never checks. /update works on request', category: 'Updates', values: UPDATE_MODES, labels: ['Automatic', 'Notify only', 'Off'], get: config => config.updateMode, set: (config, updateMode) => ({...config, updateMode})}), @@ -679,7 +681,7 @@ export const CONFIG_GROUP_BY_ID: Readonly> = { reducedMotion: 'Cursor & Motion', effectsOff: 'Cursor & Motion', shellBackend: 'Shell & Providers', suggestions: 'Shell & Providers', localUnderstanding: 'Local Understanding', - askRecord: 'Transcript & Privacy', askPresentation: 'Transcript & Privacy', outputFolding: 'Transcript & Privacy', + askRecord: 'Transcript & Privacy', askPresentation: 'Transcript & Privacy', outputFolding: 'Transcript & Privacy', semanticOutput: 'Transcript & Privacy', }; export const CONFIG_GROUP_BY_CATEGORY: Readonly> = { General: 'General', Updates: 'General', @@ -716,7 +718,7 @@ const CONFIG_ORDER: readonly string[] = [ 'sessionNotices', 'terminalTitle', 'agentActivity', 'notifications', 'shellBackend', 'welcome', 'suggestions', 'history', 'picker', 'navigation', 'tools', 'toolUpdateChecks', 'installSuggestions', 'localUnderstanding', - 'askRecord', 'askPresentation', 'outputFolding', + 'askRecord', 'askPresentation', 'outputFolding', 'semanticOutput', ]; const groupRank = (row: SettingsRow) => CONFIG_GROUPS.indexOf(configGroup(row)); /** Sort key: group, the root's place in CONFIG_ORDER, the root's own position, then the row's (so a child stays under its parent). */ diff --git a/tests/blockActions.test.ts b/tests/blockActions.test.ts index a04c50fe..33c15c19 100644 --- a/tests/blockActions.test.ts +++ b/tests/blockActions.test.ts @@ -23,7 +23,8 @@ function dispose(app: TerminalApp) { app['stop'](0); app['session'].kill(); } test('one block registry supplies keyboard palette actions and copies authoritative full plain data', () => { const output = new OutputBuffer(); const record = fixture(output); - const passive = BLOCK_ACTIONS.filter(action => action.id !== 'explain' && action.id !== 'references'); + // Conditional actions: explain (failed blocks), references (output with some), details (a Semantic Output listing). + const passive = BLOCK_ACTIONS.filter(action => action.id !== 'explain' && action.id !== 'references' && action.id !== 'details'); assert.deepEqual(blockPaletteItems(record).map(item => item.action.kind === 'block' && item.action.id), passive.map(action => action.id)); assert.ok(passive.some(action => action.id === 'clipCommand')); assert.ok(passive.some(action => action.id === 'clipOutput')); diff --git a/tests/semanticOutput.test.ts b/tests/semanticOutput.test.ts new file mode 100644 index 00000000..dee6441a --- /dev/null +++ b/tests/semanticOutput.test.ts @@ -0,0 +1,186 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {MAX_ITEMS, recognizeOutput, semanticText} from '../src/output/semantic.js'; + +const lines = (...rows: string[]) => rows.join('\n'); + +test('node --test (spec reporter): counts as printed; failing tests from its failing-tests section', () => { + const passing = recognizeOutput('npm test', lines('▶ suite', ' ✔ a (0.1ms)', '✔ suite (0.5ms)', 'ℹ tests 1797', 'ℹ suites 3', 'ℹ pass 1772', 'ℹ fail 0', + 'ℹ cancelled 0', 'ℹ skipped 25', 'ℹ todo 0', 'ℹ duration_ms 8400.1'), 0); + assert.deepEqual({source: passing?.source, outcome: passing?.outcome, overview: passing?.overview, items: passing?.items}, + {source: 'node --test', outcome: 'passed', overview: '1,772 passed · 0 failed · 25 skipped', items: []}); + const failing = recognizeOutput('node --test', lines('✔ ok test (1ms)', '✖ broken test (2ms)', ' AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:', + 'ℹ tests 2', 'ℹ pass 1', 'ℹ fail 1', 'ℹ cancelled 0', 'ℹ skipped 0', 'ℹ todo 0', '', '✖ failing tests:', '', 'test at tests/a.test.ts:3:1', + '✖ broken test (2ms)', ' AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:'), 1); + assert.equal(failing?.outcome, 'failed'); + assert.equal(failing?.overview, '1 passed · 1 failed'); + assert.deepEqual(failing?.items, [{text: 'broken test', tone: 'failure'}]); +}); + +test('TAP: the # summary and top-level not ok lines (TODO and SKIP are not failures)', () => { + const result = recognizeOutput('node --test --test-reporter=tap', lines('TAP version 13', 'ok 1 - a', 'not ok 2 - b', ' ---', ' ...', + 'not ok 3 - later # TODO', '1..3', '# tests 3', '# pass 1', '# fail 1', '# todo 1'), 1); + assert.equal(result?.overview, '1 passed · 1 failed · 1 todo'); + assert.deepEqual(result?.items.map(item => item.text), ['b']); +}); + +test('Jest, Vitest and pytest: their own summary lines and failure headings', () => { + const jest = recognizeOutput('npx jest', lines('FAIL src/a.test.js', ' ● Math › adds', '', ' expect(received).toBe(expected)', ' ● Console', 'PASS src/b.test.js', + 'Test Suites: 1 failed, 1 passed, 2 total', 'Tests: 1 failed, 1 skipped, 10 passed, 12 total'), 1); + assert.equal(jest?.source, 'Jest'); + assert.equal(jest?.overview, '10 passed · 1 failed · 1 skipped'); + assert.deepEqual(jest?.items.map(item => item.text), ['Math › adds']); + const vitest = recognizeOutput('npx vitest run', lines(' FAIL src/a.test.ts > math > adds', 'AssertionError: expected 3 to be 4', ' Test Files 1 failed | 2 passed (3)', + ' Tests 1 failed | 10 passed (11)'), 1); + assert.equal(vitest?.source, 'Vitest'); + assert.equal(vitest?.overview, '10 passed · 1 failed'); + assert.deepEqual(vitest?.items.map(item => item.text), ['src/a.test.ts > math > adds']); + const pytest = recognizeOutput('pytest -q', lines('..F.', '=========================== short test summary info ============================', + 'FAILED tests/test_a.py::test_x - assert 1 == 2', '========================= 1 failed, 3 passed, 2 skipped in 0.12s ========================='), 1); + assert.equal(pytest?.overview, '3 passed · 1 failed · 2 skipped'); + assert.deepEqual(pytest?.items.map(item => item.text), ['tests/test_a.py::test_x - assert 1 == 2']); +}); + +test('cargo test sums its test binaries; go test counts packages and failing tests', () => { + const cargo = recognizeOutput('cargo test', lines('running 3 tests', 'test a::one ... ok', 'test a::two ... FAILED', 'test a::three ... ignored', + 'test result: FAILED. 1 passed; 1 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s', 'running 2 tests', + 'test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s'), 101); + assert.equal(cargo?.overview, '3 passed · 1 failed · 1 ignored'); + assert.deepEqual(cargo?.items.map(item => item.text), ['a::two']); + const go = recognizeOutput('go test ./...', lines('--- FAIL: TestFoo (0.00s)', ' foo_test.go:9: want 1, got 2', 'FAIL', 'FAIL\texample.com/a\t0.004s', + 'ok \texample.com/b\t0.003s', 'ok \texample.com/c\t(cached)', '? \texample.com/d\t[no test files]'), 1); + assert.equal(go?.overview, '2 packages ok · 1 package failed · 1 failing test'); + assert.deepEqual(go?.items.map(item => item.text), ['TestFoo']); +}); + +test('compiler diagnostics: TypeScript (pretty and plain), Rust headings with locations, GCC/Clang', () => { + const pretty = recognizeOutput('npx tsc --noEmit', lines("src/a.ts:3:7 - error TS2322: Type 'number' is not assignable to type 'string'.", '', + '3 const x: string = 1;', ' ~', '', "src/b.ts(10,1): error TS2304: Cannot find name 'y'.", 'Found 2 errors in 2 files.'), 2); + assert.equal(pretty?.source, 'TypeScript'); + assert.equal(pretty?.overview, '2 errors in 2 files'); + assert.deepEqual(pretty?.items.map(item => item.text), ["src/a.ts:3:7 TS2322: Type 'number' is not assignable to type 'string'.", "src/b.ts:10:1 TS2304: Cannot find name 'y'."]); + const rust = recognizeOutput('cargo build', lines('warning: unused variable: `x`', ' --> src/main.rs:2:9', ' |', 'error[E0308]: mismatched types', + ' --> src/main.rs:4:18', 'error: could not compile `demo` (bin "demo") due to 1 previous error; 1 warning emitted'), 101); + assert.equal(rust?.overview, '1 error · 1 warning'); + assert.deepEqual(rust?.items, [{text: 'src/main.rs:2:9 warning: unused variable: `x`', tone: 'warning'}, {text: 'src/main.rs:4:18 error[E0308]: mismatched types', tone: 'failure'}]); + const clang = recognizeOutput('make', lines("src/a.c:3:5: error: use of undeclared identifier 'x'", 'src/a.c:4:1: warning: control reaches end of non-void function', + '1 warning and 1 error generated.'), 2); + assert.equal(clang?.source, 'GCC/Clang'); + assert.equal(clang?.overview, '1 error · 1 warning'); + assert.equal(clang?.items[1]?.tone, 'warning'); +}); + +test('fails closed: incomplete summaries, look-alike text from non-build commands, and plain output read as nothing', () => { + assert.equal(recognizeOutput('cat notes.txt', lines('# tests 2', '# fail 0'), 0), undefined, 'no pass count: not a summary'); + assert.equal(recognizeOutput('rg error', "src/a.c:3:5: error: quoted in a search result", 0), undefined, 'a successful search is not a build'); + assert.equal(recognizeOutput('git status', lines('On branch main', 'nothing to commit, working tree clean'), 0), undefined); + assert.equal(recognizeOutput('echo', '', 0), undefined); + assert.equal(recognizeOutput('go version', 'ok \texample.com/a\t0.1s', 0), undefined, 'go test lines only from go test'); + // A failing non-build command that printed compiler diagnostics still gets them (the failure makes them relevant). + assert.equal(recognizeOutput('./build.sh', "src/a.c:3:5: error: x", 1)?.source, 'GCC/Clang'); +}); + +test('items are bounded, deduplicated and counted; very large output is read from its end and says so; text export', () => { + const many = Array.from({length: 30}, (_, index) => `src/f${index}.c:1:1: error: bad ${index}`); + const result = recognizeOutput('make', lines(...many, ...many), 2)!; + assert.equal(result.items.length, MAX_ITEMS); + assert.equal(result.omitted, 10, 'duplicates are not counted twice'); + assert.equal(result.overview, '60 errors', 'the overview counts every diagnostic line printed'); + const text = semanticText(result); + assert.match(text, /^GCC\/Clang: 60 errors\nsrc\/f0\.c:1:1 error: bad 0\n/u); + assert.match(text, /… 10 more in the raw output$/u); + const huge = recognizeOutput('npm test', `${'x'.repeat(3 * 1024 * 1024)}\nℹ tests 1\nℹ pass 1\nℹ fail 0`, 0)!; + assert.equal(huge.complete, false); + assert.match(semanticText(huge), /read from the last 2 MiB/u); +}); + +// ---- Presentation and copy ------------------------------------------------------------------------------------------ + +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {logicalLines} from '../src/output/TranscriptSearch.js'; +import {parseCopyArgs, semanticCopyPayload} from '../src/clipboard/copySelection.js'; + +function block(buffer: OutputBuffer, command: string, output: string, exitCode: number) { + buffer.beginCommand(command, [`$ ${command}`]); + buffer.write(output.replace(/\n/gu, '\r\n')); + return buffer.complete(exitCode)!; +} +const passingRun = lines(...Array.from({length: 12}, (_, index) => `✔ test ${index} (1ms)`), 'ℹ tests 12', 'ℹ pass 12', 'ℹ fail 0', ''); +const failingRun = lines('✔ ok (1ms)', '✖ broken one (2ms)', '✖ broken two (2ms)', 'ℹ tests 3', 'ℹ pass 1', 'ℹ fail 2', '', '✖ failing tests:', '✖ broken one (2ms)', '✖ broken two (2ms)', ''); + +test('presentation: an overview row inside the block, details by the raw fold, nothing stored and nothing searchable', () => { + const buffer = new OutputBuffer(); + const pass = block(buffer, 'npm test', passingRun, 0); + const before = JSON.stringify(buffer.transcript().lines); + const rows = buffer.wrapped(100); + const overview = rows.filter(row => row.isSemantic); + assert.deepEqual(overview.map(row => row.plain), [' ✔ node --test · 12 passed · 0 failed']); + assert.equal(overview[0]!.blockStartId, pass.startId, 'the row belongs to its block'); + assert.ok(rows.findIndex(row => row.isSemantic) < rows.findIndex(row => row.plain.includes('test 0')), 'before the output'); + assert.equal(JSON.stringify(buffer.transcript().lines), before, 'drawing a reading stores nothing'); + assert.equal(logicalLines(rows).some(line => line.text.includes('node --test')), false, 'raw search never sees derived rows'); + + const fail = block(buffer, 'npm test', failingRun, 1); + const failRows = buffer.wrapped(100).filter(row => row.isSemantic && row.blockStartId === fail.startId); + assert.equal(fail.expanded, true, 'a short failing output stays expanded'); + assert.deepEqual(failRows.map(row => row.plain), [' ✘ node --test · 1 passed · 2 failed', ' › 2 failing tests · click or Actions to show'], + 'raw is expanded, so the details (what it shows) start closed'); + assert.equal(failRows[1]!.semanticToggle, fail.startId); + assert.ok(buffer.toggleSemanticDetails(fail.startId)); + assert.deepEqual(buffer.wrapped(100).filter(row => row.isSemantic && row.blockStartId === fail.startId).map(row => row.plain).slice(1), + [' ⌄ 2 failing tests', ' ✘ broken one', ' ✘ broken two']); + buffer.toggleExpanded(0); + assert.equal(buffer.semanticDetailsOpen(fail), true, 'folding the raw output leaves the reader\'s choice alone'); + + buffer.setSemanticOutput(false); + assert.equal(buffer.wrapped(100).some(row => row.isSemantic), false, 'Off: raw only'); + assert.equal(buffer.semanticFor(fail), undefined); +}); + +test('presentation: short passing output reads well already; failures and diagnostics are always summarized', () => { + const buffer = new OutputBuffer(); + block(buffer, 'npm test', lines('ℹ tests 1', 'ℹ pass 1', 'ℹ fail 0'), 0); + assert.equal(buffer.wrapped(80).some(row => row.isSemantic), false); + block(buffer, 'make', "a.c:1:1: error: nope", 2); + assert.deepEqual(buffer.wrapped(80).filter(row => row.isSemantic).map(row => row.plain), [' ✘ GCC/Clang · 1 error', ' › 1 diagnostic · click or Actions to show']); + block(buffer, 'git status', lines('On branch main', 'nothing to commit, working tree clean'), 0); + assert.equal(buffer.wrapped(80).filter(row => row.isSemantic).length, 2, 'unrecognized output gets nothing'); +}); + +test('/copy: --raw, --semantic and --both are one choice, never with --report or the picker; semantic copies fall back to raw once', () => { + assert.deepEqual(parseCopyArgs('2 --semantic'), {ok: true, request: {selector: {kind: 'indices', indices: [2]}, content: 'semantic'}}); + assert.deepEqual(parseCopyArgs('--both -2'), {ok: true, request: {selector: {kind: 'latest', count: 2}, content: 'both'}}); + assert.deepEqual(parseCopyArgs('--raw'), {ok: true, request: {selector: {kind: 'default'}, content: 'raw'}}); + assert.match((parseCopyArgs('--raw --semantic') as {error: string}).error, /one of --raw, --semantic or --both/u); + assert.match((parseCopyArgs('--both --report') as {error: string}).error, /report has its own format/u); + assert.match((parseCopyArgs('ui --semantic') as {error: string}).error, /copy directly/u); + const buffer = new OutputBuffer(); + const failing = block(buffer, 'npm test', failingRun, 1); + const plain = block(buffer, 'echo hi', 'hi\n', 0); + const semantic = semanticCopyPayload([failing, plain], 'semantic', false, record => buffer.semanticFor(record)); + assert.equal(semantic.payload, 'node --test: 1 passed · 2 failed\nbroken one\nbroken two\nhi\n'); + assert.equal(semantic.unavailable, 1); + const both = semanticCopyPayload([failing], 'both', false, record => buffer.semanticFor(record)); + assert.match(both.payload, /^── NMSh · raw output ──\n✔ ok \(1ms\)\n[\s\S]*✖ broken two \(2ms\)\n── NMSh · Semantic Output ──\nnode --test: 1 passed · 2 failed\nbroken one\nbroken two$/u); + assert.equal(both.payload.split('✔ ok (1ms)').length, 2, 'raw appears once'); +}); + +test('hostile output cannot make recognition slow: every pattern is linear in what it reads', () => { + const block = (line: string, times: number) => Array.from({length: times}, () => line).join('\n'); + const cases: Array<[string, string, string]> = [ + ['one 2 MB line of spaces', 'cat x', `${' '.repeat(2_000_000)}x`], + ['Jest-like lines of digits', 'npx jest', block(`Tests: ${'1'.repeat(2000)}`, 1000)], + ['TAP lines padded with spaces', 'node --test', `${block(`not ok 1 - ${' '.repeat(2000)}x`, 1000)}\n# tests 1\n# pass 0\n# fail 1`], + ['colon-digit chains', 'make', block(`${'a:1'.repeat(660)}: error: x`, 1000)], + ['TypeScript-like prefixes', 'npx tsc', block(`${'(1,'.repeat(600)} - error TS1: x`, 1000)], + ['pytest-like banners', 'pytest', block(`= ${'1 a, '.repeat(390)} in 1s =`, 1000)], + ['Rust locations', 'cargo build', block(`error: x\n --> ${':1'.repeat(1000)}`, 500)], + ['Vitest-like lines', 'npx vitest', block(` Tests ${'(1'.repeat(1000)})`, 1000)], + ]; + for (const [name, command, output] of cases) { + const started = performance.now(); + recognizeOutput(command, output, 1); + const elapsed = performance.now() - started; + assert.ok(elapsed < 750, `${name}: ${Math.round(elapsed)}ms`); + } +}); diff --git a/tests/semanticOutputLive.test.ts b/tests/semanticOutputLive.test.ts new file mode 100644 index 00000000..b82bf1d3 --- /dev/null +++ b/tests/semanticOutputLive.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** Semantic Output through the real composer and PTY: the reading is drawn in its block and /copy --semantic copies it. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: a failing test summary gets its overview and failure listing; /copy --semantic copies the reading, /copy latest the raw output', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const clip = join(sandbox.root, 'clipboard.txt'); + for (const name of ['pbcopy', 'xclip', 'wl-copy']) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > ${JSON.stringify(clip)}\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + await frontend.run("printf '✔ ok (1ms)\\n✖ broken (2ms)\\nℹ tests 2\\nℹ pass 1\\nℹ fail 1\\n\\n✖ failing tests:\\n✖ broken (2ms)\\n'; false", + /node --test · 1 passed · 1 failed[\s\S]*1 failing test/u); + let mark = frontend.mark; + frontend.pty.write('/copy --semantic\r'); + await frontend.waitFor(/Copied[^\n]*semantic/u, mark); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8').includes('broken'), 15_000, 'the reading on the clipboard'); + assert.equal(readFileSync(clip, 'utf8'), 'node --test: 1 passed · 1 failed\nbroken'); + mark = frontend.mark; + frontend.pty.write('/copy latest\r'); + await until(() => readFileSync(clip, 'utf8').startsWith('✔ ok (1ms)'), 15_000, 'the raw output on the clipboard'); + assert.doesNotMatch(readFileSync(clip, 'utf8'), /node --test:/u, '/copy without --semantic stays raw'); + } finally { await sandbox.dispose(); } +}); diff --git a/tests/transcriptRecallQa.test.ts b/tests/transcriptRecallQa.test.ts index 323afed9..919bd084 100644 --- a/tests/transcriptRecallQa.test.ts +++ b/tests/transcriptRecallQa.test.ts @@ -115,7 +115,7 @@ test('Setup Cat: a Transcript step after Editor, reusing the transcript rows onc const ids = SETUP_SECTIONS.map(section => section.id); assert.equal(ids.indexOf('transcript'), ids.indexOf('editor') + 1); const transcript = SETUP_SECTIONS[sectionIndex('transcript')]!.rows.map(item => item.row.id); - assert.deepEqual(transcript, ['transcriptPresentation', 'historicalPrompt', 'historyColors', 'historyTheme', 'divider', 'dividerDensity', 'dividerColors', 'outputFolding']); + assert.deepEqual(transcript, ['transcriptPresentation', 'historicalPrompt', 'historyColors', 'historyTheme', 'divider', 'dividerDensity', 'dividerColors', 'outputFolding', 'semanticOutput']); const all = SETUP_SECTIONS.flatMap(section => section.rows.map(item => item.row.id)); assert.equal(all.filter(id => id === 'transcriptPresentation').length, 1, 'Transcript presentation appears once'); assert.equal(all.filter(id => id === 'outputFolding').length, 1);