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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

- **`/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.
- **`/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.

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions docs/development/v021-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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 | `feature/v021-view` (stacked on slice 1) | | PR open |
| 3 | `/edit` | #402 | | | |
| 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open |
| 4 | Semantic Output | #386 | | | |
| 5 | `/track` | #393 (#394) | | | |
| 6 | Modules and diagnostics | #364, #365 | | | |
Expand All @@ -35,7 +35,7 @@ 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).
NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals).

## Verification notes

Expand Down
19 changes: 19 additions & 0 deletions docs/testing/v021-physical-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
| 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 | |
| 3 `/edit` | Ghostty | macOS | zsh | | Not run | |
| 3 `/edit` | Terminal.app | macOS | Bash or Fish | | Not run | |

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

Expand Down Expand Up @@ -50,3 +52,20 @@
- [ ] 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).

## 3. Native editor (`/edit`)

- [ ] 3.1 `/edit notes.txt` in an empty folder: it says *new file*; type two lines with Enter and a Tab, Ctrl+S says
*Saved*, Esc returns to the composer, `cat -A notes.txt` shows the tab and a final newline.
- [ ] 3.2 Shift+arrows, Shift+Alt+arrows and Cmd+Shift+arrows select; typing replaces the selection; Ctrl+C copies
and Ctrl+X cuts it (paste it back with the terminal's paste); Ctrl+Z undoes a typed word in one step, Ctrl+Y redoes.
- [ ] 3.3 Ctrl+F finds (lowercase ignores case), Enter again finds the next match.
- [ ] 3.4 Edit, then Esc: the question offers Save, Discard changes and Keep editing; each does what it says.
- [ ] 3.5 Conflict: `/edit notes.txt`, change it from another terminal (`echo x >> notes.txt`), then Ctrl+S: nothing is
written until you choose; Overwrite writes yours, Reload shows theirs.
- [ ] 3.6 A CRLF file with a BOM keeps both after saving (`xxd file | head -1`); `chmod 640` survives a save; editing
through a symlink keeps the symlink.
- [ ] 3.7 Refusals: a binary (`/edit /bin/ls`), a root-owned file (`/edit /etc/hosts`), a 2 MB file, a read-only file
(`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.
75 changes: 74 additions & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ 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 {createEditor, editorKey, renderEditor, type EditorAction, type EditorState} from '../viewer/EditorPanel.js';
import {openForEdit, saveFile} from '../viewer/fileEdit.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 @@ -1622,11 +1624,20 @@ export class TerminalApp {
this.render();
return;
}
if (this.textEditor) {
const editor = this.textEditor;
const {columns, rows} = this.dimensions();
const action = editorKey(editor, key, columns, rows - 4);
if (action) this.runEditorAction(editor, action);
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 === 'edit') { this.viewer = undefined; this.openTextEditor(action.path, '/edit', viewer); }
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(); } });
}
Expand Down Expand Up @@ -2887,6 +2898,10 @@ export class TerminalApp {
}
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 === 'editFile') {
if (!slash.target) this.output.addFrontendInteraction(command.trim(), 'Name a file: /edit notes.md. Your own editor and vim, nano or micro still work as usual.', INFO);
else this.openTextEditor(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 @@ -3603,6 +3618,61 @@ export class TerminalApp {
});
}

/**
* /edit: open one small text file in NMSh's editor, or say in the transcript why it cannot be edited (refused files
* stay readable in /view). `returnTo` is the viewer it was opened from; it is reopened if the file cannot be edited.
*/
private openTextEditor(target: string, command: string, returnTo?: ViewerState): void {
void openForEdit(target, this.shellCwd).then(result => {
if (this.stopped) return;
if (!result.ok) {
if (returnTo && !this.hasModalInput()) { returnTo.note = result.reason; this.viewer = returnTo; }
else this.output.addFrontendInteraction(command, result.reason, ERROR);
this.render();
return;
}
if (this.hasModalInput()) return;
this.panelOrigin = undefined;
this.textEditor = createEditor(result.file);
this.render();
});
}

/** The editor's requests that touch the disk or the clipboard; the panel shows the outcome. */
private runEditorAction(editor: EditorState, action: EditorAction): void {
if (action.kind === 'close') { this.textEditor = undefined; this.returnFromPanel(); return; }
if (action.kind === 'copy') {
void this.copyText(action.text, 'Copied').then(note => { if (note && this.textEditor === editor) { editor.note = {text: note, tone: 'success'}; this.render(); } });
return;
}
editor.busy = true;
if (action.kind === 'reload') {
void openForEdit(editor.file.path, this.shellCwd).then(result => {
editor.busy = false;
if (this.textEditor !== editor) return;
if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; this.render(); return; }
this.textEditor = {...createEditor(result.file), note: {text: 'Reloaded from disk; your changes were discarded.', tone: 'info'}};
this.render();
});
return;
}
const lines = [...editor.buffer.lines];
void saveFile(editor.file, lines, {...(action.force ? {force: true} : {})}).then(result => {
editor.busy = false;
if (this.textEditor !== editor) return;
if (result.ok) {
editor.file = result.file;
// Only what was written is clean: typing during the save stays unsaved.
if (editor.buffer.lines.length === lines.length && editor.buffer.lines.every((line, index) => line === lines[index])) editor.buffer.markSaved();
if (action.thenClose) { this.textEditor = undefined; this.returnFromPanel(); }
else editor.note = {text: `Saved ${editor.file.title}.`, tone: 'success'};
} else if (result.conflict) {
editor.dialog = {kind: 'conflict', focus: 0, conflict: result.conflict, reason: result.reason, ...(action.thenClose ? {thenClose: true} : {})};
} else editor.note = {text: `${result.reason} Your text is still here.`, tone: 'error'};
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;
Expand Down Expand Up @@ -4964,7 +5034,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.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
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.textEditor || 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 @@ -5007,6 +5077,7 @@ export class TerminalApp {
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.textEditor) return renderEditor(this.textEditor, columns, this.dimensions().rows - 4);
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 @@ -5939,6 +6010,8 @@ export class TerminalApp {
private viewer?: ViewerState;
/** A file /view is still reading; a newer request or Esc cancels it. */
private viewerLoad?: AbortController;
/** NMSh's small text editor (/edit, e in /view); owns keys while open. */
private textEditor?: EditorState;
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 @@ -256,6 +256,9 @@ export const CONCEPTS: readonly Concept[] = [
{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: 'edit-file', label: 'Edit a small text file', support: 'actionable', open: '/edit', covers: ['/edit'],
aliases: ['edit a file', 'edit file', 'edit text file', 'change a config file', 'quick edit', 'small editor', 'native editor', 'edit notes'],
description: '/edit notes.md opens a small text file (up to 1 MiB, your own, UTF-8) in NMSh\'s editor: type, select with Shift, Ctrl+Z undo, Ctrl+Y redo, Ctrl+F find, Ctrl+C/Ctrl+X copy and cut, Ctrl+S saves (atomically; if the file changed on disk you choose Overwrite, Reload or Keep editing), Esc closes and asks about unsaved changes. e in /view edits the file shown. vim, nano, micro and your $EDITOR 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 @@ -367,7 +370,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', 'view', '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', 'edit-file', '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
Loading
Loading