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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/design/v021-terminal-intelligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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 @@ -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 | | | |
Expand All @@ -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

Expand Down
16 changes: 16 additions & 0 deletions docs/testing/v021-physical-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
36 changes: 27 additions & 9 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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};
Expand All @@ -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;
Expand All @@ -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') {
Expand Down Expand Up @@ -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};
}
Expand Down Expand Up @@ -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;
Expand All @@ -3719,17 +3732,19 @@ 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);
}

/**
* The one clipboard write for command output (/copy, /cp, the picker, block Copy and Copy output): the records'
* 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<boolean> {
private async copyRecords(records: readonly CompletedCommand[], includeStatus: boolean, before?: string, content: CopyContent = 'raw'): Promise<boolean> {
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');
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading