Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **`/view`, a native read-only viewer** for files and completed output ([#397](https://github.com/raiseCatError/notMyShell/issues/397)). `/view README.md` opens a file relative to the shell's directory (`~` for home): Markdown renders (`m` shows the source), source files get syntax colors and line numbers, `.diff`/`.patch` files and `git diff`/`git show` output read as diffs. `/view N` opens completed output N (numbered as `/copy` numbers it) and `/view` the latest; Actions → View output does the same for a block. Find with `/` and `n`/`N`, go to a line with `:`, `w` toggles wrapping (Left/Right scroll sideways), `l` line numbers, `c` copies the whole text, Esc closes. Viewing never changes the file or the transcript.
- Limits: regular files only (directories, devices and pipes are refused), at most 8 MiB and 200,000 lines (the rest is named, with `less` or an editor as the way to read it), binary and UTF-16 files are refused, invalid UTF-8 is shown with `�` and a note. Control characters, escape sequences and invisible format characters are shown as visible marks, never interpreted. Open in pager, `less`, `bat` and Glow are unchanged.

### Fixed

- Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Absolute cursor positioning keeps the previous behavior.
Expand Down
5 changes: 3 additions & 2 deletions docs/development/v021-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Automated results here are not physical-terminal validation.
| --- | --- | --- | --- | --- | --- |
| 0 | Design and ledger | #410 | `docs/v021-design` / #411 | | PR open, CI green; awaiting merge |
| 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open |
| 2 | `/view` | #397, #402 | | | |
| 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open |
| 3 | `/edit` | #402 | | | |
| 4 | Semantic Output | #386 | | | |
| 5 | `/track` | #393 (#394) | | | |
Expand All @@ -34,7 +34,8 @@ Automated results here are not physical-terminal validation.
## Physical QA still required

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).
and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text,
NO_COLOR).

## Verification notes

Expand Down
21 changes: 21 additions & 0 deletions docs/testing/v021-physical-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
|---|---|---|---|---|---|---|
| 1 Multi-row progress (#388) | Ghostty | macOS | zsh | | Not run | |
| 1 Multi-row progress (#388) | Terminal.app | macOS | Bash or Fish | | Not run | |
| 2 `/view` | Ghostty | macOS | zsh | | Not run | |
| 2 `/view` | Terminal.app | macOS | zsh | | Not run | |

## 1. Multi-row progress redraws (#388)

Expand All @@ -29,3 +31,22 @@
- [ ] 1.6 `vim`, `less` and `htop` still take the full screen and return to the composer.
- [ ] 1.7 Queue two quick commands (`ls` then `pwd`) with Ctrl+Q while a short command runs: both stored command
lines end with the known-command color (#408).

## 2. Native viewer (`/view`, View output)

- [ ] 2.1 `/view README.md` in this repository: Markdown is rendered (headings, lists, code, tables); `m` shows the
source with line numbers; `m` again returns.
- [ ] 2.2 `/view src/viewer/ViewerPanel.ts`: syntax colors, line numbers, `w` turns wrapping off and Left/Right scroll
sideways with `‹`/`›` marks, `l` hides numbers.
- [ ] 2.3 `/` finds text (lowercase ignores case), `n`/`N` step and wrap with a note, `:` + number goes to a line,
`G`/`g` end and start, PageUp/PageDown, mouse wheel scrolls.
- [ ] 2.4 Run `seq 1 5000`, then `/view` (latest output) and Actions → View output on that block; `/view 2` opens the
previous output. The transcript is unchanged after closing.
- [ ] 2.5 `c` copies the whole document; the clipboard has the exact text (tabs kept), and nothing else.
- [ ] 2.6 Hostile content: `printf '\033]8;;https://example.com\a\033[31mred\033[2J\n' > /tmp/h.txt && /view /tmp/h.txt`
shows `␛` marks, no color change, no link, no screen clear.
- [ ] 2.7 Refusals say why: a directory, a missing file, `/dev/zero`, a FIFO (`mkfifo /tmp/f && /view /tmp/f` returns
at once), a binary (`/view /bin/ls`), a 20 MB log (shows the first 8 MiB with a note).
- [ ] 2.8 Esc closes, the composer draft and cursor are where they were, and a running command's output kept
arriving underneath. At 32×10 the viewer still fits and Esc is shown.
- [ ] 2.9 `NO_COLOR=1` and Safe glyphs: matches and the current match are still visible (reverse video).
66 changes: 65 additions & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, scanSensitive, typ
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 {loadFileDocument, outputDocument} from '../viewer/document.js';
import {createViewer, renderViewer, viewerKey, type ViewerLook, type ViewerState} from '../viewer/ViewerPanel.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';
Expand Down Expand Up @@ -1620,6 +1622,17 @@ export class TerminalApp {
this.render();
return;
}
if (this.viewer) {
const viewer = this.viewer;
const {columns, rows} = this.dimensions();
const action = viewerKey(viewer, key, columns, rows - 4, this.viewerLook());
if (action?.kind === 'close') { this.viewer = undefined; this.returnFromPanel(); }
else if (action?.kind === 'copy') {
void this.copyText(action.text, 'Copied the document').then(note => { if (note && this.viewer === viewer) { viewer.note = note; this.render(); } });
}
this.render();
return;
}
if (this.comparePanel) {
const {columns, rows} = this.dimensions();
const action = comparePanelKey(this.comparePanel, key, columns, rows - 4);
Expand Down Expand Up @@ -2873,6 +2886,7 @@ export class TerminalApp {
else this.configureClipboardChanges(slash.suggestions);
}
else if (slash.kind === 'pins') { this.panelOrigin = undefined; this.openPins(); }
else if (slash.kind === 'view') this.openViewer(slash.target, command.trim());
else if (slash.kind === 'compare') {
if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR);
else {
Expand Down Expand Up @@ -3230,6 +3244,7 @@ export class TerminalApp {
try { await writeClipboard(record.command); this.noteClipboard(`Copied command · ${copyStats(record.command).characters.toLocaleString()} characters`, 'success'); }
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 === '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') {
Expand Down Expand Up @@ -3553,6 +3568,50 @@ export class TerminalApp {
finally { rmSync(directory, {recursive: true, force: true}); this.render(); }
}

private viewerLook(): ViewerLook {
return {syntax: this.syntaxSgr, hyperlinks: this.host.capabilities.hyperlinks};
}

/**
* /view: a completed output by its /copy number (the latest when no target is given), or a file relative to the
* shell's directory. The file is read asynchronously and read-only; a failure is reported in the transcript.
*/
private openViewer(target: string, command: string): void {
this.viewerLoad?.abort();
this.viewerLoad = undefined;
if (!target || /^[1-9]\d{0,2}$/u.test(target)) {
const number = target ? Number(target) : 1;
const record = this.output.recentShellCommands()[number - 1];
if (!record) {
this.output.addFrontendInteraction(command, target ? `No completed command output at ${number}.` : 'No completed output yet. /view <file> opens a file.', target ? ERROR : INFO);
return;
}
this.openOutputViewer(record);
return;
}
const load = new AbortController();
this.viewerLoad = load;
void loadFileDocument(target, this.shellCwd, {signal: load.signal}).then(result => {
if (this.viewerLoad !== load || this.stopped) return;
this.viewerLoad = undefined;
if (!result.ok) { this.output.addFrontendInteraction(command, result.reason, ERROR); this.render(); return; }
// Another panel opened while the file was read: it keeps the keys; the file is not opened over it.
if (this.hasModalInput()) return;
this.panelOrigin = undefined;
this.viewer = createViewer(result.document);
this.render();
});
}

/** View output: the record's stored output, numbered as /copy numbers it. */
private openOutputViewer(record: CompletedCommand): void {
const number = this.output.recentShellCommands().indexOf(record) + 1;
this.panelOrigin = undefined;
// The text /copy copies (tabs as the program wrote them), without the optional completion status.
this.viewer = createViewer(outputDocument({command: record.command, output: recordCopyText(record, false), exitCode: record.exitCode}, Math.max(1, number)));
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;
Expand Down Expand Up @@ -4905,7 +4964,7 @@ export class TerminalApp {
private get settingsPanelActive(): boolean { return this.hasModalInput(); }

private hasModalInput(ignorePalette = false): boolean {
return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.background.panel || this.batchReview || this.pinsPanel || 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 || (!ignorePalette && 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.summaryReview || this.reportReview || this.comparePanel || this.viewer || this.background.panel || this.batchReview || this.pinsPanel || 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 || (!ignorePalette && 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.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel);
}

Expand Down Expand Up @@ -4947,6 +5006,7 @@ export class TerminalApp {
if (this.summaryReview) return renderReportReview(this.summaryReview.document, 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.viewer) return renderViewer(this.viewer, columns, this.dimensions().rows - 4, this.viewerLook());
if (this.background.panel) return framePanel(this.background.panelRows(Math.max(1, columns - 4), this.dimensions().rows - 4,
{now: Date.now(), color: colorLevel() !== 'none', safe: getCurrentGlyphMode() === 'safe'}), columns);
if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.rowPanelPreview(columns));
Expand Down Expand Up @@ -5875,6 +5935,10 @@ export class TerminalApp {
private reportReview?: ReportReviewState;
/** Compare output…: choosing the other block, then the difference. */
private comparePanel?: ComparePanelState;
/** The native read-only viewer (/view, View output); owns keys while open. */
private viewer?: ViewerState;
/** A file /view is still reading; a newer request or Esc cancels it. */
private viewerLoad?: AbortController;
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 @@ -253,6 +253,9 @@ export const CONCEPTS: readonly Concept[] = [
{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: 'view', label: 'View a file or output', support: 'actionable', open: '/view', covers: ['/view'],
aliases: ['view file', 'view a file', 'read a file', 'open a file read only', 'view output', 'read markdown', 'preview markdown', 'view log', 'pager'],
description: '/view opens a read-only native viewer: /view README.md for a file (Markdown renders; m shows the source), /view N for completed output N (numbered as /copy), /view alone for the latest output. Also Actions → View output. Find with /, n/N, go to a line with :, w wraps, l numbers, c copies the whole text. less, bat and Glow still work as usual.'},
{id: 'paste-batch', label: 'Paste and queue a batch', support: 'actionable', open: '/ps', covers: ['/ps', '/clips'],
aliases: ['paste commands', 'paste batch', 'paste from clipboard', 'queue pasted commands', 'review pasted commands', 'ps command', 'run as block'],
description: '/ps reads the clipboard once and shows Paste Review. B reviews a safe batch with choices to run as one block, queue separately, edit or cancel; quotes, loops and heredocs stay whole. C deliberately keeps a clip in memory; /clips reviews it before insertion. Clips disappear when this frontend exits. Nothing executes automatically.'},
Expand Down Expand Up @@ -364,7 +367,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', 'output-actions', 'compare', 'transcripts', 'pins']},
{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', 'output-actions', 'view', 'compare', 'transcripts', 'pins']},
{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
5 changes: 5 additions & 0 deletions src/commands/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [
{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 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: '/compare', insertion: '/compare', description: 'Compare two outputs: /compare (latest with its previous run), /compare N, /compare N M (numbers as /copy)'},
{name: '/pins', insertion: '/pins', description: 'Commands and recipes you kept: stage a pin, or review and queue a recipe (nothing runs by itself)'},
{name: '/ps', insertion: '/ps', description: 'Paste from the clipboard, reviewed first: read when you run it, never in the background'},
Expand Down Expand Up @@ -168,6 +169,8 @@ export type ParsedSlashCommand =
| {kind: 'pins'}
/** /compare [A [B]]: outputs by /copy number; A defaults to the latest, B to A's previous run (or a choice). */
| {kind: 'compare'; first?: number; second?: number; error?: string}
/** /view [path | N]: a file (relative to the shell's directory, ~ for home), output N as /copy numbers it, or the latest output. */
| {kind: 'view'; target: string}
| {kind: 'appearance'}
| {kind: 'motion'}
| {kind: 'prompt'}
Expand Down Expand Up @@ -263,6 +266,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined
const clips = /^\/clips(?:\s+suggestions\s+(on|off))?\s*$/u.exec(input);
if (clips) return {kind: 'clips', ...(clips[1] ? {suggestions: clips[1] === 'on'} : {})};
if (/^\/pins\s*$/u.test(input)) return {kind: 'pins'};
const view = /^\/view(?:\s+([\s\S]*))?$/u.exec(input);
if (view) return {kind: 'view', target: (view[1] ?? '').trim()};
// /cp is /copy: one parser, one handler, every form.
if (/^\/(?:copy|cp)\s+settings\s*$/u.test(input)) return {kind: 'copySettings'};
const copy = /^\/(?:copy|cp)(?:\s+(.*))?$/su.exec(input);
Expand Down
Loading
Loading