From 6cd9dd149258722753aaee58cc2e92bcdf5532e9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:26:49 +0530 Subject: [PATCH 1/2] /track: one live result of a finite command, refreshed in place (#393) /track (or --every 5s -- ) writes the command and a note once, then draws a status row and the latest completed result under them, replaced on every refresh with a stable height. Live rows are derived: never stored, found, selected or copied, so nothing accumulates per refresh. /track copy, refresh, pause, resume and stop; stopping leaves the last result in the transcript once. /watch keeps its manager and behavior. The shared scheduler takes draft PR #394's hardening as written (strict cadence parsing; refusing substitutions, background jobs and heredocs; UTF-8-safe bounded capture with a truncation flag; visible timeouts and spawn errors; completion on stdio close; a stop grace period; at most 8 trackers), and its tests. Samples now use the live shell's PATH. The derived-row flag from Semantic Output is generalized for both. --- CHANGELOG.md | 1 + .../development/track-live-command-refresh.md | 57 ++++ docs/development/v021-ledger.md | 8 +- docs/testing/v021-physical-qa.md | 18 ++ src/app/TerminalApp.ts | 95 +++++-- src/ask/concepts.ts | 6 +- src/commands/slashCommands.ts | 16 +- src/output/OutputBuffer.ts | 24 ++ src/output/OutputSelection.ts | 2 +- src/output/TranscriptPresenter.ts | 4 + src/output/TranscriptSearch.ts | 2 +- src/output/TranscriptSelection.ts | 2 +- src/output/semanticRows.ts | 2 +- src/output/viewport.ts | 5 +- src/tasks/WatchPanel.ts | 29 ++- src/tasks/WatchTasks.ts | 155 +++++++++-- src/tasks/trackBlock.ts | 80 ++++++ tests/semanticOutput.test.ts | 16 +- tests/track.test.ts | 246 ++++++++++++++++++ tests/trackApp.test.ts | 54 ++++ tests/trackLive.test.ts | 47 ++++ 21 files changed, 791 insertions(+), 78 deletions(-) create mode 100644 docs/development/track-live-command-refresh.md create mode 100644 src/tasks/trackBlock.ts create mode 100644 tests/track.test.ts create mode 100644 tests/trackApp.test.ts create mode 100644 tests/trackLive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e33080a1..08a47da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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. +- **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394). ### Fixed diff --git a/docs/development/track-live-command-refresh.md b/docs/development/track-live-command-refresh.md new file mode 100644 index 00000000..01366024 --- /dev/null +++ b/docs/development/track-live-command-refresh.md @@ -0,0 +1,57 @@ +# Live command tracking (`/track`) + +`/track` (#393) keeps **one live result** of a finite command in the transcript, refreshed in place, while the shell +stays free for other commands. It runs on the existing `/watch` scheduler (`src/tasks/WatchTasks.ts`), whose hardening +was first written in draft PR #394 and carried into v0.21; `/watch` keeps its own behavior (its manager panel and +what-changed view) and both names manage the same trackers. + +## Usage + +```sh +/track df -h /Volumes/T7Projects +/track --every 5s -- docker system df +/track # the manager (same as /watch) +/track refresh [N] # one refresh now (never a second, overlapping one) +/track pause [N] · /track resume [N] +/track copy [N] # copy the latest completed result, as plain text +/track stop [N] · /track stop all +``` + +`N` is the tracker's position in the manager (the newest when omitted). The `--` after the cadence is optional but +keeps a command's own flags from reading as NMSh's. + +## What the transcript shows + +- The command line and one note, stored once like any NMSh block: what is tracked, where, and how it runs. +- Under them, drawn live and **never stored**: a status row and the latest completed result. The status says only what + is known: first run pending, `live · every 5s · updated 13:49:25 · exit 0 · 12 runs`, a failing exit code, a timeout, + a spawn error, `paused`, `no new result for 1m 20s` (stale), `refreshing…` while a sample runs. +- The result keeps a stable height (the largest seen, at most 12 rows) so a shorter refresh never moves what is below; + a longer one says how many lines are not shown. `/track copy` copies all of it. +- Live rows are presentation: find, selection and `/copy` never include them, and nothing accumulates per refresh. +- Stopping a tracker (`/track stop`, or S in the manager) writes its **last result once** into the transcript, so + history keeps it. A tracker whose window closes leaves only its stored lines. + +## Execution and safety + +- Every sample is `sh -c ` in its own process group, from the directory the tracker started in, with NMSh's + environment and the live shell's PATH. It does not see the shell's aliases, functions, unexported variables or + options, and it does not follow later `cd`s; the note in the block says so. +- Default interval 5 s; at least 2 s locally and 15 s for remote network targets; at most 60 minutes. Invalid + intervals fail before anything runs. At most 8 active trackers. A sample never overlaps another of the same tracker; + a refresh while one runs is refused with the reason. +- Repeated commands can repeat side effects. Read-only commands (and the exact `docker system df`) start at once; + unknown ones need an explicit Yes; installing, modifying, destroying, privileged, downloaded-code, `cd`, substitution, + background (`&`) and heredoc commands are refused. +- Each sample keeps at most 200 kB of stdout and stderr (decoded as UTF-8 across chunk boundaries), says when it was + cut, and has its terminal control sequences removed. A sample is stopped after the lesser of 60 s or three + intervals and shown as timed out. Stopping signals the owned process group (TERM, then KILL after a grace period); + processes that deliberately leave the group are not followed. + +## Ownership and limits + +Trackers belong to the window that started them, as `/watch` always has: detaching or closing the window stops them +and their processes. They are not restarted on reattach; the stored lines remain. Service-owned tracking that survives +detach would reuse the session's background-task supervisor (#382) and is not part of v0.21. + +Physical terminal checks are listed in `docs/testing/v021-physical-qa.md`. diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index e644a606..aea9bb28 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -19,7 +19,7 @@ Automated results here are not physical-terminal validation. | 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 | `feature/v021-semantic` (stacked on slice 3) | | PR open | -| 5 | `/track` | #393 (#394) | | | | +| 5 | `/track` | #393 (#394) | `feature/v021-track` (stacked on slice 4) | | PR open | | 6 | Modules and diagnostics | #364, #365 | | | | | 7 | Themes and controls | #401, #336 | | | | @@ -30,13 +30,17 @@ Automated results here are not physical-terminal validation. - #408 is a production race in `SemanticService` (superseded lookups resolved `unknown`), plus a test that raced the real shell's startup knowledge. - `/track` stays window-owned like `/watch` in v0.21; service-owned scheduling is deferred and documented. +- PR #394's `WatchTasks.ts`/`WatchPanel.ts` were adopted as written (neither file changed on `dev` since its base) and + its tests kept; its other edits were re-applied by hand. #394 itself is superseded by the slice 5 PR and was not merged. +- `/track` samples run `sh -c` (as `/watch` always has) with the live shell's PATH, not the session shell, so `/watch` + semantics stay the same for Fish users. ## 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). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). Slice 4: section 4 (overview rows, details -toggle, copy flags, Off). +toggle, copy flags, Off). Slice 5: section 5 (`/track` block, refresh, copy, stop, cleanup). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 370770e2..ed702e27 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -21,6 +21,8 @@ | 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 | | +| 5 `/track` | Ghostty | macOS | zsh | | Not run | | +| 5 `/track` | Terminal.app | macOS | Fish | | Not run | | ## 1. Multi-row progress redraws (#388) @@ -85,3 +87,19 @@ 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. + +## 5. `/track` + +- [ ] 5.1 `/track df -h` and (where Docker runs) `/track --every 5s -- docker system df`: one block, a status row and the + result; it updates in place every interval while you run other commands; scrolling up and selecting text in other + blocks is never disturbed. +- [ ] 5.2 Make the result shorter and longer (`/track --every 2s -- cat f.txt` while editing `f.txt`): shorter results + do not move what is below; longer ones say how many lines are not shown. +- [ ] 5.3 `/track refresh` twice quickly: the second is refused, never overlapped. `/track pause`, `/track resume`. +- [ ] 5.4 A failing command (`/track --every 2s -- ls missing`): the status shows the exit code, never a healthy result. +- [ ] 5.5 `/track copy` copies only the latest result; find (Ctrl+F) never matches the live rows. +- [ ] 5.6 `/track stop`: the last result appears once in the transcript; `/resume` of the saved session shows it + without any per-refresh copies. +- [ ] 5.7 An unknown command asks for Yes first; `rm`, `sudo`, `cd`, `$(...)`, `&` and heredocs are refused. +- [ ] 5.8 Close the window with a tracker running: no `sh` or command processes are left (`ps`). `/watch git status` + still opens its manager. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c3d84b49..e12189a7 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -68,7 +68,7 @@ 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 {displayPath, 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'; @@ -268,8 +268,9 @@ import {openableUrl, projectRunAllowed, readProjectFacts} from '../ask/project.j import {ManagedTasks} from '../tasks/ManagedTasks.js'; import {CursorPresenter} from '../cursor/CursorPresenter.js'; import {runDoctor} from '../doctor/doctor.js'; -import {parseWatch, watchSafety, WatchTasks} from '../tasks/WatchTasks.js'; +import {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js'; import {renderWatchPanel, watchPanelKey, watchRow, type WatchPanelState} from '../tasks/WatchPanel.js'; +import {finalResultRows, trackBlockRows} from '../tasks/trackBlock.js'; import {analyzePaste, KIND_LABELS, needsPreview, PASTE_EXACT_NOTE, pasteHeader, primaryKind, type PasteAnalysis, nmshCommandNote} from '../input/pasteGuard.js'; import type {BatchShell} from '../input/pasteBatch.js'; import {createPasteReview, displaySafe, pasteReviewKey, renderPasteReview, type PasteReviewState} from '../input/PasteReview.js'; @@ -940,6 +941,7 @@ export class TerminalApp { this.output.presenter.setTreatment(this.promptConfiguration.presentation); this.output.setOutputFolding(this.promptConfiguration.outputFolding); this.output.setSemanticOutput(this.promptConfiguration.semanticOutput); + this.output.setLiveRows((id, width) => this.trackRows(id, width)); 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)); @@ -1928,13 +1930,28 @@ export class TerminalApp { if (action?.kind === 'close') { this.watchPanel = undefined; this.returnFromPanel(); } else if (action?.kind === 'confirm' && panel.confirm) { const pending = panel.confirm; + const size = this.dimensions(); + if (size.columns < 40 || size.rows < 14) { + panel.message = 'Resize to at least 40 columns and 14 rows to review this recurring command.'; + this.render(); + return; + } panel.confirm = undefined; const safety = watchSafety(pending.command); if (safety.kind === 'refused') panel.message = safety.reason; - else { this.watches.start(pending.command, pending.cwd, pending.intervalMs, safety); panel.selected = this.watches.active().length - 1; } + else if (this.startWatch(pending.track ? `/track ${pending.command}` : `/watch ${pending.command}`, pending.command, pending.cwd, pending.intervalMs, safety, Boolean(pending.track))) { + // A confirmed /track shows its block in the transcript; the manager closes. + if (pending.track) { this.watchPanel = undefined; this.returnFromPanel(); } + else panel.selected = this.watches.active().length - 1; + } } else if (action?.kind === 'pause') this.watches.pause(action.id); else if (action?.kind === 'resume') this.watches.resume(action.id); - else if (action?.kind === 'now') this.watches.runNow(action.id); + else if (action?.kind === 'now' && !this.watches.runNow(action.id)) panel.message = 'A result is still being produced; no second one was started.'; + else if (action?.kind === 'copy') { + const latest = this.watches.get(action.id)?.current; + if (!latest) panel.message = 'No completed result to copy yet.'; + else void this.copyText(latest.output, 'Copied the latest result').then(note => { if (note && this.watchPanel === panel) { panel.message = note; this.render(); } }); + } else if (action?.kind === 'stop') this.watches.stop(action.id); this.render(); return; @@ -3013,7 +3030,7 @@ export class TerminalApp { else if (slash.kind === 'providers') this.openProvidersOverview(slash.family); else if (slash.kind === 'llm') this.openUnderstandingPanel(); else if (slash.kind === 'doctor') void this.openDoctor(); - else if (slash.kind === 'watch') this.handleWatch(command, slash.op, slash.arguments); + else if (slash.kind === 'watch') this.handleWatch(command.trim(), slash.op, slash.arguments, slash.track === true); else if (slash.kind === 'queue') this.handleQueueCommand(command, slash.op); else if (slash.kind === 'rename') { if (this.sessionMode !== 'service' || !this.sessionId) this.output.addFrontendInteraction(command, 'Renaming needs a live session (this one runs in-process).', INFO); @@ -8662,16 +8679,45 @@ export class TerminalApp { private doctorPanel?: DoctorPanelState; private watchPanel?: WatchPanelState; /** Watches NMSh schedules itself; they live (and stop) with this window. */ - private readonly watches = new WatchTasks(resolveCommand('sh') ?? '/bin/sh'); - private readonly watchSubscription = this.watches.onChange((_watch, transition) => { + private readonly watches = new WatchTasks(resolveCommand('sh') ?? '/bin/sh', Date.now, () => ({...process.env, PATH: this.shellPath()})); + /** Watches started with /track: their live transcript block keeps the height it reached (see trackBlockRows). */ + private readonly trackBlocks = new Map(); + private readonly watchSubscription = this.watches.onChange((watch, transition) => { if (this.stopped) return; // A watch moving between failing and passing is one meaningful event. if (transition) this.transitions.echo(transition === 'pass' ? 'taskDone' : 'taskFailed', Date.now()); + // A tracker the person stopped leaves its last result in the transcript once (a closing window leaves nothing). + const block = this.trackBlocks.get(watch.id); + if (block && watch.status === 'stopped' && !block.finalized) { + block.finalized = true; + this.output.addFrontendBlock(`/track ${watch.command}`, finalResultRows(watch)); + } this.render(); }); + /** A /track block's live rows (presentation only). */ + private trackRows(id: string, width: number): string[] { + const block = this.trackBlocks.get(id); + const {rows, reserved} = trackBlockRows(this.watches.get(id), width, Date.now(), block?.reserved ?? 0, this.watches.sampling(id)); + if (block) block.reserved = reserved; + return rows; + } + + /** Start a classified (or confirmed) watch; /track also writes its block, whose live rows follow it in place. */ + private startWatch(command: string, watched: string, cwd: string, intervalMs: number | undefined, safety: Exclude, track: boolean): boolean { + let watch; + try { watch = this.watches.start(watched, cwd, intervalMs, safety); } + catch (error) { this.output.addFrontendInteraction(command, error instanceof Error ? error.message : 'Could not start tracking.', ERROR); return false; } + if (track) { + this.trackBlocks.set(watch.id, {reserved: 0, finalized: false}); + const place = cwd === this.shellCwd ? 'here' : `in ${displayPath(cwd)}`; + this.output.addTrackBlock(watch.id, command, [`${GLYPHS.info} Tracking ${watch.command} ${place} with sh -c and your shell's PATH (not its aliases or functions). /track stop ends it.`]); + } + return true; + } + /** /watch …: start (classified first), list, pause, resume, run now, stop. */ - private handleWatch(command: string, op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start', argument: string): void { + private handleWatch(command: string, op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy', argument: string, track = false): void { const active = this.watches.active(); const pick = () => { const index = /^\d+$/u.test(argument) ? Number(argument) - 1 : active.length - 1; @@ -8679,24 +8725,41 @@ export class TerminalApp { }; if (op === 'list') { this.panelOrigin = undefined; this.watchPanel = {selected: 0, output: false}; return; } if (op === 'start') { - const {command: watched, intervalMs} = parseWatch(argument); + const parsed = parseWatch(argument); + if (parsed.error) { this.output.addFrontendInteraction(command, parsed.error, ERROR); return; } + const {command: watched, intervalMs} = parsed; const safety = watchSafety(watched); if (safety.kind === 'refused') { this.output.addFrontendInteraction(command, safety.reason, ERROR); return; } this.panelOrigin = undefined; if (safety.kind === 'confirm' || safety.remote) { this.watchPanel = {selected: 0, output: false, confirm: {command: watched, cwd: this.shellCwd, ...(intervalMs ? {intervalMs} : {}), - reason: safety.kind === 'confirm' ? safety.reason : 'It contacts a remote host every interval (at least every 15s).', choice: 'no'}}; + reason: safety.kind === 'confirm' ? safety.reason : 'It contacts a remote host every interval (at least every 15s).', choice: 'no', ...(track ? {track: true} : {})}}; return; } - this.watches.start(watched, this.shellCwd, intervalMs, safety); - this.watchPanel = {selected: this.watches.active().length - 1, output: false}; + // /track shows its result in the transcript; /watch opens its manager as before. + if (this.startWatch(command, watched, this.shellCwd, intervalMs, safety, track) && !track) this.watchPanel = {selected: this.watches.active().length - 1, output: false}; + return; + } + const noun = track ? 'tracker' : 'watch'; + if (op === 'stop' && argument === 'all') { + for (const watch of active) this.watches.stop(watch.id); + this.output.addFrontendInteraction(command, active.length ? `Stopped every ${noun}; their processes are being ended.` : `Nothing is being ${track ? 'tracked' : 'watched'}.`, INFO); return; } - if (op === 'stop' && argument === 'all') { for (const watch of active) this.watches.stop(watch.id); this.output.addFrontendInteraction(command, 'Stopped every watch.', INFO); return; } const watch = pick(); - if (!watch) { this.output.addFrontendInteraction(command, 'Nothing is being watched.', INFO); return; } - const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : (this.watches.runNow(watch.id), true); - this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Running' : op === 'stop' ? 'Stopped' : op === 'pause' ? 'Paused' : 'Resumed'} the watch on ${watch.command}.` : 'Nothing changed.', INFO); + if (!watch) { this.output.addFrontendInteraction(command, `Nothing is being ${track ? 'tracked' : 'watched'}.`, INFO); return; } + if (op === 'copy') { + if (!watch.current) { this.output.addFrontendInteraction(command, `No completed result of ${watch.command} yet.`, INFO); return; } + // The latest completed result only, as plain text: never earlier refreshes, never the block's own lines. + void this.copyText(watch.current.output, `Copied the latest result of ${watch.command.slice(0, 40)}`); + return; + } + if (op === 'now' && !this.watches.runNow(watch.id)) { + this.output.addFrontendInteraction(command, `A result of ${watch.command} is still being produced; no second one was started.`, INFO); + return; + } + const done = op === 'stop' ? this.watches.stop(watch.id) : op === 'pause' ? this.watches.pause(watch.id) : op === 'resume' ? this.watches.resume(watch.id) : true; + this.output.addFrontendInteraction(command, done ? `${op === 'now' ? 'Refreshing' : op === 'stop' ? 'Stopped' : op === 'pause' ? 'Paused' : 'Resumed'} the ${noun} on ${watch.command}.` : 'Nothing changed.', INFO); } /** /doctor: local, bounded, read-only checks gathered in parallel; a live line while they run. */ diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index c5bdbda4..a4a9c32e 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -158,9 +158,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'doctor', label: 'Doctor', support: 'actionable', open: '/doctor', covers: ['/doctor'], aliases: ['doctor', 'health check', 'diagnostics', 'diagnose', 'check my setup', 'is everything ok', 'whats wrong with my setup', 'check environment'], description: '/doctor checks NMSh, the shell, this project, Git, tools, the local model and the host terminal with local, read-only checks, and offers actions that open the right place; it never fixes anything silently.'}, - {id: 'watch', label: 'Watch', support: 'actionable', open: '/watch', covers: ['/watch'], - aliases: ['watch', 'watch a command', 'rerun every', 'repeat a command', 'keep running', 'monitor a command'], - description: '/watch runs a command on NMSh\'s own schedule and shows what changed in one live block (pause, run now, stop). Commands that install, modify or destroy are refused; unknown ones need a Yes.'}, + {id: 'watch', label: 'Watch and track', support: 'actionable', open: '/watch', covers: ['/watch', '/track'], + aliases: ['watch', 'watch a command', 'rerun every', 'repeat a command', 'keep running', 'monitor a command', 'track a command', 'live result', 'refresh a command'], + description: '/track df -h keeps one live result of a finite command in the transcript, refreshed in place every few seconds (/track --every 5s -- cmd; pause, resume, refresh, copy, stop; stopping leaves the last result once). /watch runs the same scheduler with its manager (what changed between runs). Both run sh -c with your shell\'s PATH, not its aliases; commands that install, modify or destroy are refused and unknown ones need a Yes. Trackers stop with the window that started them.'}, {id: 'pastePreview', label: 'Paste preview', support: 'settings', open: '/settings', where: 'Settings → Editor → Paste preview (Smart, Always, Off)', covers: ['settings:Editor'], aliases: ['paste preview', 'paste guard', 'pasting', 'paste safety', 'pasted commands'], description: 'Multiline, chained, mutating or risky pastes are shown (muted, above the composer) before they enter it; Insert keeps the exact text, and nothing runs until you press Enter.'}, diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index 76d2b91b..430f61fb 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -128,7 +128,8 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/qc', insertion: '/qc', alias: '/queue', description: 'Review before clearing queued commands'}, {name: '/pause', insertion: '/pause', alias: '/queue', description: 'Pause the command queue'}, {name: '/queue', insertion: '/queue', description: 'Execution timeline and queue manager (/q): /qp or /pause pauses, /qr reviews resume, /qc reviews clear; /queue pause|resume|clear, /queue history reviews finished chains, /queue save-summary saves the newest after a review, /queue settings opens its Settings'}, - {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume)'}, + {name: '/watch', insertion: '/watch ', description: 'Run a command repeatedly and show what changed (/watch git status · --every 10s · /watch stop|pause|resume|refresh)'}, + {name: '/track', insertion: '/track ', description: 'Keep one live result of a finite command in the transcript, refreshed in place (/track df -h · /track --every 5s -- docker system df · pause|resume|refresh|stop|copy)'}, {name: '/doctor', insertion: '/doctor', description: 'Health check: NMSh, shell, project, Git, tools, local model and host (local, read-only)'}, {name: '/llm', insertion: '/llm', description: 'Local Intelligence: the optional local model for Ask and Smart Folding (status, setup, stop, remove)'}, {name: '/providers', insertion: '/providers', description: 'What NMSh uses for prompt, welcome, suggestions, history and more; switch, install, detect'}, @@ -232,7 +233,7 @@ export type ParsedSlashCommand = | {kind: 'providers'; family?: 'prompt' | 'welcome' | 'suggestions' | 'history' | 'picker' | 'navigation'} | {kind: 'llm'} | {kind: 'doctor'} - | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start'; arguments: string} + | {kind: 'watch'; op: 'list' | 'stop' | 'pause' | 'resume' | 'now' | 'start' | 'copy'; arguments: string; /** Typed as /track: keep one live result in the transcript. */ track?: boolean} | {kind: 'queue'; op: 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'} | {kind: 'rename'; name: string} | {kind: 'directories', query: string} @@ -387,11 +388,14 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined const queue = /^\/(?:queue|q)(?:\s+(pause|resume|clear|settings|history|save-summary))?\s*$/u.exec(input); if (queue) return queue[1] === 'settings' ? {kind: 'settings', view: 'config', destination: 'queue'} : {kind: 'queue', op: (queue[1] ?? 'open') as 'open' | 'pause' | 'resume' | 'clear' | 'history' | 'save-summary'}; - const watch = /^\/watch(?:\s+(.*))?$/u.exec(input); + // /track shares the /watch scheduler, safety gate and manager; it also keeps one live result in the transcript. + const watch = /^\/(watch|track)(?:\s+(.*))?$/u.exec(input); if (watch) { - const rest = (watch[1] ?? '').trim(); - const op = /^(stop|pause|resume|now)(?:\s+(.*))?$/u.exec(rest); - return op ? {kind: 'watch', op: op[1] as 'stop' | 'pause' | 'resume' | 'now', arguments: (op[2] ?? '').trim()} : {kind: 'watch', op: rest ? 'start' : 'list', arguments: rest}; + const track = watch[1] === 'track'; + const rest = (watch[2] ?? '').trim(); + const op = new RegExp(`^(stop|pause|resume|now|refresh${track ? '|copy' : ''})(?:\\s+(.*))?$`, 'u').exec(rest); + const kind = op ? (op[1] === 'refresh' ? 'now' : op[1]) as 'stop' | 'pause' | 'resume' | 'now' | 'copy' : rest && rest !== 'list' ? 'start' : 'list'; + return {kind: 'watch', op: kind, arguments: op ? (op[2] ?? '').trim() : rest === 'list' ? '' : rest, ...(track ? {track: true} : {})}; } const rename = /^\/rename(?:\s+(.*))?$/u.exec(input); if (rename) return {kind: 'rename', name: (rename[1] ?? '').trim()}; diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index 7122f456..beca8f74 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -160,6 +160,9 @@ export class OutputBuffer { private semanticOutput = true; private readonly semanticReadings = new WeakMap(); private readonly semanticDetails = new Map(); + /** /track: the stored line each live result is drawn under (line id → tracker id), and who draws it. */ + private readonly trackAnchors = new Map(); + private liveRowsProvider?: (id: string, width: number) => readonly string[]; private readonly parser: AnsiOutputParser; private readonly completed: CompletedCommand[] = []; private readonly visualGaps = new Set(); @@ -202,6 +205,7 @@ export class OutputBuffer { this.completed.length = 0; this.userToggled.clear(); this.semanticDetails.clear(); + this.trackAnchors.clear(); this.setOutputFilter(undefined); if (!this.active) return; this.active.start = 0; @@ -278,6 +282,7 @@ export class OutputBuffer { this.parser.restore([]); this.completed.length = 0; this.semanticDetails.clear(); + this.trackAnchors.clear(); this.visualGaps.clear(); this.lineTypes.clear(); this.historicalContexts.clear(); @@ -624,6 +629,10 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), + liveRows: (line, width) => { + const id = this.trackAnchors.get(line); + return id === undefined ? undefined : this.liveRowsProvider?.(id, width); + }, semantic: record => { const result = this.semanticFor(record); return result ? {result, open: this.semanticDetailsOpen(record, result)} : undefined; @@ -633,6 +642,21 @@ export class OutputBuffer { setSemanticOutput(on: boolean): void { this.semanticOutput = on; } + /** Who draws each /track block's live rows (presentation only; nothing it returns is stored). */ + setLiveRows(provider: (id: string, width: number) => readonly string[]): void { this.liveRowsProvider = provider; } + + /** + * A /track block: NMSh's lines for the command, stored once like any frontend block; the tracker's live result is + * drawn under its last line and replaced in place on every refresh. Returns that line's id. + */ + addTrackBlock(id: string, command: string, rows: readonly string[]): number { + this.addFrontendBlock(command, rows); + const anchor = this.parser.completedCount() - 1; + this.trackAnchors.set(anchor, id); + return anchor; + } + + /** * 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. diff --git a/src/output/OutputSelection.ts b/src/output/OutputSelection.ts index 45501707..c120f96b 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 || row.isSemantic ? 'fold' : row.isHistoricalHeader ? 'historical' : row.isFilterHint ? 'filter' : 'line'; +const rowKind = (row: WrappedRow) => row.isFoldHint || row.derived ? '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 d237d730..cac7b8ca 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -79,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[]; + /** A /track block's live rows under this stored line, replaced on every refresh; undefined for other lines. */ + liveRows?: (lineIndex: number, width: number) => readonly string[] | undefined; /** The block's Semantic Output reading and whether its details are open (#386); undefined: raw only. */ semantic?: (record: CompletedCommand) => {result: SemanticResult; open: boolean} | undefined; } @@ -285,6 +287,8 @@ export class TranscriptPresenter { else if (cmdIndex !== -1) row.commandIndex = cmdIndex; result.push(row); } + // A /track block's live result, under its stored line (derived: never stored, searched, selected or copied). + for (const ansi of view.liveRows?.(i, width) ?? []) result.push({ansi: truncateAnsi(ansi, width), plain: stripAnsi(truncateAnsi(ansi, width)), lineIndex: i, derived: true}); } if (active) { for (const activity of active.activities) { diff --git a/src/output/TranscriptSearch.ts b/src/output/TranscriptSearch.ts index a1b5d0fd..b249a9a1 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 || row.isSemantic || !include(row)) { current = undefined; currentIndex = undefined; return; } + if (row.isFilterHint || row.derived || !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 fb8708a4..2d20f914 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 || row.isSemantic) continue; + if (row.isFilterHint || row.derived) 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/semanticRows.ts b/src/output/semanticRows.ts index bc8f085e..4f17a87a 100644 --- a/src/output/semanticRows.ts +++ b/src/output/semanticRows.ts @@ -25,7 +25,7 @@ export function semanticRows(result: SemanticResult, open: boolean, width: numbe 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 base = {lineIndex: owner.lineIndex, commandIndex: owner.commandIndex, derived: 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}`}]; diff --git a/src/output/viewport.ts b/src/output/viewport.ts index 0cc72b4a..89e4e46c 100644 --- a/src/output/viewport.ts +++ b/src/output/viewport.ts @@ -20,8 +20,9 @@ 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; + /** Derived presentation (a Semantic Output reading, a /track live result): never transcript content, so search, + * selection and copy skip it. */ + derived?: boolean; /** Clicking this row opens or closes the Semantic Output details of the block with this startId. */ semanticToggle?: number; } diff --git a/src/tasks/WatchPanel.ts b/src/tasks/WatchPanel.ts index 2fddd282..fae7ff3d 100644 --- a/src/tasks/WatchPanel.ts +++ b/src/tasks/WatchPanel.ts @@ -5,20 +5,21 @@ import {foreground, UI_COLORS} from '../ui/palette.js'; import {truncateAnsi} from '../util/text.js'; import type {WatchTask} from './WatchTasks.js'; -/** /watch: one live block per watch (current, previous, what changed), never a pile of repeated output. */ +/** /track and /watch share one live snapshot view: no duplicated transcript output. */ export interface WatchPanelState { selected: number; /** Showing the selected watch's current output. */ output: boolean; /** A watch waiting for the person's explicit Yes (unknown command, or a network target). */ - confirm?: {command: string; cwd: string; intervalMs?: number; reason: string; choice: 'yes' | 'no'}; + confirm?: {command: string; cwd: string; intervalMs?: number; reason: string; choice: 'yes' | 'no'; /** Started as /track: it gets a live transcript block. */ track?: boolean}; message?: string; } -export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now'; id: string} | {kind: 'confirm'}; +export type WatchPanelAction = {kind: 'close'} | {kind: 'pause' | 'resume' | 'stop' | 'now' | 'copy'; id: string} | {kind: 'confirm'}; const formatEvery = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; const ago = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +const runStatus = (run: WatchTask['current']): string => !run ? 'waiting for first result' : run.timedOut ? 'timed out' : run.error ? 'process error' : run.signal ? `signal ${run.signal}` : `exit ${run.exitCode}`; export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonly WatchTask[]): WatchPanelAction | undefined { if (state.confirm) { @@ -37,6 +38,7 @@ export function watchPanelKey(state: WatchPanelState, key: Key, watches: readonl const letter = key.value.toLowerCase(); if (letter === 'p') return {kind: watch.status === 'paused' ? 'resume' : 'pause', id: watch.id}; if (letter === 'r') return {kind: 'now', id: watch.id}; + if (letter === 'c') return {kind: 'copy', id: watch.id}; if (letter === 's') return {kind: 'stop', id: watch.id}; } return undefined; @@ -47,10 +49,11 @@ export function watchRow(watch: WatchTask, now: number): string { const subtle = foreground(UI_COLORS.subtle); const secondary = foreground(UI_COLORS.secondary); const reset = '\u001b[0m'; - const state = watch.current ? (watch.current.exitCode === 0 ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; + const state = watch.current ? (watch.current.exitCode === 0 && !watch.current.timedOut && !watch.current.error ? foreground(UI_COLORS.success) : foreground(UI_COLORS.failure)) : subtle; const mark = watch.status === 'paused' ? '‖' : '◉'; - return `${state}${mark}${reset} ${secondary}Watching · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` - + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : ` · ${ago(now - watch.startedAt)}`}${reset}`; + const last = watch.current ? ` · updated ${ago(Math.max(0, now - watch.current.at - watch.current.durationMs))} ago · ${runStatus(watch.current)}` : ' · first run pending'; + return `${state}${mark}${reset} ${secondary}Track · ${watch.command.slice(0, 40)}${reset}${subtle} · every ${formatEvery(watch.intervalMs)}${watch.summary ? ` · ${watch.summary}` : ''}` + + `${watch.changes.length ? ' · changed' : ''} · runs ${watch.runs}${watch.status === 'paused' ? ' · paused' : last}${reset}`; } export function renderWatchPanel(state: WatchPanelState, watches: readonly WatchTask[], columns: number, now: number, rowsAvailable: number): string[] { @@ -59,8 +62,12 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch const subtle = foreground(UI_COLORS.subtle); const accent = foreground(UI_COLORS.accent); const reset = '\u001b[0m'; - const rows = [`${primary} Watch${reset}`]; + const rows = [`${primary} Live commands · /track / /watch${reset}`]; if (state.confirm) { + if (columns < 40 || rowsAvailable < 10) { + return [`${primary} Resize to review tracked command (40 columns, 14 rows)${reset}`, + `${secondary} Confirmation is disabled at this size. Esc cancels.${reset}`].map(row => truncateAnsi(row, columns)); + } const yes = state.confirm.choice === 'yes'; rows.push('', ` ${primary}Watch ${state.confirm.command}?${reset}`, ` ${secondary}${state.confirm.reason}${reset}`, ` ${subtle}It runs in ${state.confirm.cwd}, every ${formatEvery(state.confirm.intervalMs ?? 5000)}, until you stop it.${reset}`, '', @@ -69,7 +76,7 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch return rows.map(row => truncateAnsi(row, columns)); } if (!watches.length) { - rows.push('', ` ${secondary}Nothing is being watched.${reset}`, ` ${subtle}/watch git status · /watch --every 10s npm test · stop with /watch stop${reset}`); + rows.push('', ` ${secondary}No active command trackers.${reset}`, ` ${subtle}/track df -h /Volumes/T7Projects · /track --every 5s -- docker system df${reset}`); if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); rows.push('', renderControls([['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); @@ -80,14 +87,14 @@ export function renderWatchPanel(state: WatchPanelState, watches: readonly Watch if (state.output) { const lines = (watch.current?.output ?? '').split('\n'); const room = Math.max(4, rowsAvailable - rows.length - 3); - rows.push(` ${subtle}Current output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); + rows.push(` ${subtle}Latest completed output · ${lines.length} lines${lines.length > room ? ` (last ${room})` : ''} · ${runStatus(watch.current)}${watch.current?.truncated ? ' · output truncated' : ''}${reset}`, ...lines.slice(-room).map(line => ` ${line}`)); } else { - rows.push(` ${subtle}Current${reset} ${secondary}${watch.summary ?? (watch.current ? `exit ${watch.current.exitCode}` : 'running…')}${reset}`); + rows.push(` ${subtle}Current${reset} ${secondary}${watch.current?.timedOut || watch.current?.error || watch.current?.signal ? runStatus(watch.current) : watch.summary ?? (watch.current ? runStatus(watch.current) : 'running…')}${watch.current?.truncated ? ' · truncated' : ''}${reset}`); if (watch.previous) rows.push(` ${subtle}Previous${reset} ${secondary}${watch.previousSummary ?? `exit ${watch.previous.exitCode}`}${reset}`); rows.push(` ${subtle}Changed${reset} ${watch.changes.length ? '' : `${secondary}nothing since the last run${reset}`}`, ...watch.changes.map(line => ` ${line.startsWith('✗') || line.startsWith('-') ? foreground(UI_COLORS.failure) : line.startsWith('✓') || line.startsWith('+') ? foreground(UI_COLORS.success) : secondary}${line}${reset}`)); rows.push(` ${subtle}Runs ${watch.runs} · ${ago(now - watch.startedAt)}${watch.network ? ' · repeated network access' : ''}${reset}`); } if (state.message) rows.push('', ` ${secondary}${state.message}${reset}`); - rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['S', 'stop'], ['Esc', 'close']])); + rows.push('', renderControls([['↑↓', 'select'], ['Enter', state.output ? 'summary' : 'view output'], ['P', watch.status === 'paused' ? 'resume' : 'pause'], ['R', 'run now'], ['C', 'copy latest'], ['S', 'stop'], ['Esc', 'close']])); return rows.map(row => truncateAnsi(row, columns)); } diff --git a/src/tasks/WatchTasks.ts b/src/tasks/WatchTasks.ts index d051bab1..0512aa0c 100644 --- a/src/tasks/WatchTasks.ts +++ b/src/tasks/WatchTasks.ts @@ -1,5 +1,7 @@ import {spawn, type ChildProcess} from 'node:child_process'; +import {StringDecoder} from 'node:string_decoder'; import {classifyCommand, splitCommands, type PasteKind} from '../input/pasteGuard.js'; +import {stripTerminalControls} from '../util/terminalControls.js'; /** * /watch: NMSh runs a command again and again on its own schedule (never a @@ -19,9 +21,27 @@ const REFUSED: ReadonlySet = new Set(['install', 'modifies', 'destruc export const DEFAULT_INTERVAL_MS = 5000; export const MIN_LOCAL_MS = 2000; export const MIN_REMOTE_MS = 15000; +export const MAX_INTERVAL_MS = 3_600_000; +export const MAX_ACTIVE_WATCHES = 8; +const MAX_COMMAND = 4096; const MAX_OUTPUT = 200_000; +const STOP_GRACE_MS = 1500; export function watchSafety(command: string): WatchSafety { + if (!command.trim() || command.length > MAX_COMMAND || /[\u0000-\u001f\u007f]/u.test(command)) { + return {kind: 'refused', reason: 'A tracked command must be nonempty, single-line and at most 4096 characters.'}; + } + // A harmless-looking first word is not evidence that embedded shell execution is safe. + // Fail closed rather than scheduling substitutions, backticks or process substitutions. + if (/\$\(|`|[<>]\(/u.test(command)) { + return {kind: 'refused', reason: 'Shell and process substitutions cannot be tracked repeatedly.'}; + } + const outsideQuotes = command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/gu, '""'); + // Detached shell jobs would outlive the observed sample and multiply on every + // tick; heredocs also need a different stdin/input-ownership contract. + if (/(?:^|[^&])&(?!&)|<.'}; const value = Number(every[1]); const unit = every[2] ?? 's'; - const intervalMs = unit === 'ms' ? value : /^m/u.test(unit) && unit !== 'ms' ? value * 60_000 : value * 1000; - return {command: text.trim().slice(every[0].length).trim(), intervalMs}; + const multiplier = unit === 'ms' ? 1 : /^(?:m|min|minute)/u.test(unit) ? 60_000 : 1000; + const intervalMs = value * multiplier; + if (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS) { + return {command: '', error: 'Interval must be positive and at most 60 minutes.'}; + } + const command = input.slice(every[0].length).replace(/^--(?:\s+|$)/u, '').trim(); + if (!command) return {command: '', error: 'Provide a finite command after the interval.'}; + return {command, intervalMs}; } -export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number} +export interface WatchRun {output: string; exitCode: number | null; at: number; durationMs: number; truncated?: boolean; timedOut?: boolean; error?: string; signal?: string | null} export interface WatchTask { id: string; @@ -109,9 +141,17 @@ export class WatchTasks { readonly watches: WatchTask[] = []; private readonly timers = new Map(); private readonly children = new Map(); + private readonly killTimers = new Map(); private readonly listeners = new Set<(watch: WatchTask, transition?: 'pass' | 'fail') => void>(); - constructor(private readonly shell: string = '/bin/sh', private readonly now: () => number = Date.now, private readonly env: NodeJS.ProcessEnv = process.env) {} + /** + * `env` may be a function, read at every sample: the window passes its launch environment with the live shell's + * PATH, so a tracked command finds what the shell finds (never its aliases, functions or unexported variables). + */ + constructor(private readonly shell: string = '/bin/sh', private readonly now: () => number = Date.now, private readonly env: NodeJS.ProcessEnv | (() => NodeJS.ProcessEnv) = process.env) {} + + /** Whether a sample of this watch is running now. */ + sampling(id: string): boolean { return this.children.has(id); } onChange(listener: (watch: WatchTask, transition?: 'pass' | 'fail') => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private emit(watch: WatchTask, transition?: 'pass' | 'fail'): void { for (const listener of this.listeners) listener(watch, transition); } @@ -121,6 +161,17 @@ export class WatchTasks { /** Start watching a command already judged allowed (or confirmed). The first run starts at once. */ start(command: string, cwd: string, intervalMs: number | undefined, safety: Exclude): WatchTask { + if (this.active().length >= MAX_ACTIVE_WATCHES) throw new Error(`At most ${MAX_ACTIVE_WATCHES} active trackers can run.`); + // Stopped rows do not appear in the manager; do not retain unlimited stale records. + if (this.watches.length >= 128) { + for (let index = 0; index < this.watches.length && this.watches.length > 64;) { + if (this.watches[index]?.status === 'stopped') this.watches.splice(index, 1); + else index += 1; + } + } + if (intervalMs !== undefined && (!Number.isFinite(intervalMs) || intervalMs <= 0 || intervalMs > MAX_INTERVAL_MS)) { + throw new Error('Invalid tracking interval.'); + } counter += 1; const minimum = safety.remote ? MIN_REMOTE_MS : MIN_LOCAL_MS; const watch: WatchTask = {id: `watch-${counter}`, command, cwd, intervalMs: Math.max(minimum, intervalMs ?? DEFAULT_INTERVAL_MS), startedAt: this.now(), runs: 0, @@ -131,30 +182,69 @@ export class WatchTasks { } /** Run once now (skipped while a run is still in flight: runs never overlap). */ - runNow(id: string): void { + runNow(id: string): boolean { const watch = this.get(id); - if (!watch || watch.status === 'stopped' || this.children.has(id)) return; + if (!watch || watch.status === 'stopped' || this.children.has(id)) return false; + const timer = this.timers.get(id); + if (timer) clearTimeout(timer); + this.timers.delete(id); const started = this.now(); let output = ''; + let receivedBytes = 0; + let truncated = false; + let timedOut = false; + let spawnError: string | undefined; + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); let child: ChildProcess; try { - child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...this.env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); - } catch (error) { this.record(watch, {output: String(error), exitCode: -1, at: started, durationMs: 0}); return; } + const env = typeof this.env === 'function' ? this.env() : this.env; + child = spawn(this.shell, ['-c', watch.command], {cwd: watch.cwd, env: {...env, NO_COLOR: '1', FORCE_COLOR: '0'}, detached: true, stdio: ['ignore', 'pipe', 'pipe']}); + } catch (error) { + this.record(watch, {output: stripTerminalControls(String(error), MAX_OUTPUT), exitCode: -1, at: started, durationMs: 0, error: 'spawn failed'}); + return true; + } this.children.set(id, child); - const take = (chunk: Buffer) => { if (output.length < MAX_OUTPUT) output += chunk.toString().slice(0, MAX_OUTPUT - output.length); }; - child.stdout?.on('data', take); - child.stderr?.on('data', take); - // A run never outlives a few intervals (bounded at a minute). - const limit = setTimeout(() => { try { if (child.pid) process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } }, Math.min(60_000, watch.intervalMs * 3)); + const take = (decoder: StringDecoder, chunk: Buffer) => { + const kept = Math.min(chunk.length, Math.max(0, MAX_OUTPUT - receivedBytes)); + if (kept) { receivedBytes += kept; output += decoder.write(chunk.subarray(0, kept)); } + if (kept < chunk.length) truncated = true; + }; + child.stdout?.on('data', (chunk: Buffer) => take(stdoutDecoder, chunk)); + child.stderr?.on('data', (chunk: Buffer) => take(stderrDecoder, chunk)); + // Timeout is not a successful completion, even when the shell exits on a signal. + const limit = setTimeout(() => { + timedOut = true; + this.signalGroup(child, 'SIGKILL'); + // A detached descendant may retain pipe FDs after the tracked shell dies. + // Stop consuming it; the sample must not wait on an unrelated pipe holder. + child.stdout?.destroy(); + child.stderr?.destroy(); + }, Math.min(60_000, watch.intervalMs * 3)); limit.unref(); - const done = (code: number | null) => { + child.on('error', error => { spawnError = String(error); }); + // Only 'close' guarantees the captured stdio has finished, unlike 'exit'. + child.on('close', (code, signal) => { clearTimeout(limit); + const killTimer = this.killTimers.get(id); + if (killTimer) clearTimeout(killTimer); + this.killTimers.delete(id); if (this.children.get(id) !== child) return; this.children.delete(id); - this.record(watch, {output: output.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, ''), exitCode: code, at: started, durationMs: this.now() - started}); - }; - child.on('error', () => done(-1)); - child.on('exit', code => done(code)); + output += stdoutDecoder.end() + stderrDecoder.end(); + const sanitized = output.split('\n').map(line => stripTerminalControls(line, MAX_OUTPUT)).join('\n'); + this.record(watch, {output: sanitized, exitCode: code ?? -1, at: started, durationMs: this.now() - started, + ...(truncated ? {truncated} : {}), ...(timedOut ? {timedOut} : {}), + ...(spawnError ? {error: stripTerminalControls(spawnError, 300)} : {}), + ...(signal ? {signal} : {})}); + }); + return true; + } + + private signalGroup(child: ChildProcess, signal: NodeJS.Signals): void { + if (!child.pid) return; + try { process.kill(-child.pid, signal); } + catch { try { child.kill(signal); } catch { /* already exited */ } } } private record(watch: WatchTask, run: WatchRun): void { @@ -200,15 +290,28 @@ export class WatchTasks { return true; } - /** Stop: no more runs, the in-flight run's process group is ended, nothing is left behind. */ + /** Stop scheduling, signal the owned process group, and bound graceful termination. */ stop(id: string): boolean { const watch = this.get(id); if (!watch || watch.status === 'stopped') return false; watch.status = 'stopped'; this.schedule(watch); const child = this.children.get(id); - this.children.delete(id); - if (child?.pid) { try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch { /* gone */ } } } + if (child) { + this.signalGroup(child, 'SIGTERM'); + const pending = this.killTimers.get(id); + if (pending) clearTimeout(pending); + const killTimer = setTimeout(() => { + this.killTimers.delete(id); + if (this.children.get(id) === child) { + this.signalGroup(child, 'SIGKILL'); + child.stdout?.destroy(); + child.stderr?.destroy(); + } + }, STOP_GRACE_MS); + killTimer.unref(); + this.killTimers.set(id, killTimer); + } this.emit(watch); return true; } diff --git a/src/tasks/trackBlock.ts b/src/tasks/trackBlock.ts new file mode 100644 index 00000000..07df7446 --- /dev/null +++ b/src/tasks/trackBlock.ts @@ -0,0 +1,80 @@ +import {displaySafe} from '../input/PasteReview.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {truncateAnsi, truncateText} from '../util/text.js'; +import type {WatchRun, WatchTask} from './WatchTasks.js'; + +/** + * A /track block's live rows (#393), drawn under its stored lines and replaced on every refresh: a status row that + * says only what is known (first run pending, refreshing, when the last result completed and how, paused, stale, + * stopped), then the latest completed result. The result keeps a stable height (the largest seen, at most + * MAX_TRACK_ROWS) so a shorter refresh never moves what is below it; a longer one says how much is not shown. + */ + +export const MAX_TRACK_ROWS = 12; +const RESET = '\u001b[0m'; + +const clock = (at: number) => new Date(at).toLocaleTimeString('en-GB', {hour12: false}); +const every = (ms: number) => ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 100) / 10}s`; +const span = (ms: number) => ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; + +/** How a completed sample ended, in words: never a healthy result when it was not one. */ +export function runOutcome(run: WatchRun): {text: string; ok: boolean} { + if (run.timedOut) return {text: `timed out after ${span(run.durationMs)}`, ok: false}; + if (run.error) return {text: `could not run: ${run.error}`, ok: false}; + if (run.signal) return {text: `ended by ${run.signal}`, ok: false}; + return run.exitCode === 0 ? {text: 'exit 0', ok: true} : {text: `exit ${run.exitCode}`, ok: false}; +} + +export function trackStatus(watch: WatchTask, now: number, sampling: boolean): string { + const color = colorLevel() !== 'none'; + const paint = (role: keyof typeof UI_COLORS) => color ? foreground(UI_COLORS[role]) : ''; + const run = watch.current; + const outcome = run && runOutcome(run); + const finished = run ? run.at + run.durationMs : undefined; + const parts: string[] = []; + let mark: string; + let role: keyof typeof UI_COLORS = 'subtle'; + if (watch.status === 'stopped') { mark = '■'; parts.push('stopped'); } + else if (watch.status === 'paused') { mark = '‖'; parts.push('paused'); } + else if (!run) { mark = '◌'; parts.push(sampling ? 'first run…' : 'first run pending'); } + else { mark = '◉'; role = outcome!.ok ? 'success' : 'failure'; parts.push('live'); } + parts.push(`every ${every(watch.intervalMs)}`); + if (run && outcome) parts.push(`updated ${clock(finished!)}`, outcome.text); + if (run?.truncated) parts.push('output cut at 200 kB'); + if (watch.status === 'running' && run && now - finished! > watch.intervalMs * 2 + 5000) parts.push(`no new result for ${span(now - finished!)}`); + if (watch.status === 'running' && run && sampling) parts.push('refreshing…'); + parts.push(`${watch.runs} run${watch.runs === 1 ? '' : 's'}`); + return `${paint(role)}${mark}${RESET} ${paint('secondary')}${parts.join(' · ')}${RESET}`; +} + +export function trackBlockRows(watch: WatchTask | undefined, width: number, now: number, reserved: number, sampling = false): {rows: string[]; reserved: number} { + const color = colorLevel() !== 'none'; + const subtle = color ? foreground(UI_COLORS.subtle) : ''; + const fit = (text: string) => truncateText(text, Math.max(1, width - 4)); + if (!watch) return {rows: [` ${subtle}■ not running: trackers stop with the window that started them${RESET}`], reserved: 0}; + const status = truncateAnsi(` ${trackStatus(watch, now, sampling)}`, width); + // Stopped by the person: the final result was written into the transcript; the live rows end here. + if (watch.status === 'stopped') return {rows: [status], reserved: 0}; + const lines = watch.current ? watch.current.output.replace(/\n$/u, '').split('\n') : []; + const height = Math.max(reserved, Math.min(MAX_TRACK_ROWS, Math.max(1, lines.length))); + const body: string[] = []; + if (!watch.current) body.push(` ${subtle}waiting for the first result…${RESET}`); + else { + const take = lines.length > height ? height - 1 : lines.length; + for (const line of lines.slice(0, take)) body.push(` ${fit(displaySafe(line))}`); + if (take < lines.length) body.push(` ${subtle}${fit(`… ${lines.length - take} more lines · /track copy copies them all`)}${RESET}`); + } + while (body.length < height) body.push(''); + return {rows: [status, ...body], reserved: height}; +} + +/** The final result written into the transcript when the person stops a tracker: bounded, plain, said once. */ +export function finalResultRows(watch: WatchTask, maxLines = 200): string[] { + const run = watch.current; + if (!run) return [`Stopped before the first result · ${watch.runs} runs`]; + const lines = run.output.replace(/\n$/u, '').split('\n'); + const kept = lines.slice(0, maxLines).map(line => displaySafe(line)); + return [`Stopped · last result ${clock(run.at + run.durationMs)} · ${runOutcome(run).text} · ${watch.runs} run${watch.runs === 1 ? '' : 's'}`, ...kept, + ...(lines.length > maxLines ? [`… ${lines.length - maxLines} more lines were not kept`] : [])]; +} diff --git a/tests/semanticOutput.test.ts b/tests/semanticOutput.test.ts index dee6441a..63b3cb1c 100644 --- a/tests/semanticOutput.test.ts +++ b/tests/semanticOutput.test.ts @@ -113,38 +113,38 @@ test('presentation: an overview row inside the block, details by the raw fold, n 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); + const overview = rows.filter(row => row.derived); 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.ok(rows.findIndex(row => row.derived) < 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); + const failRows = buffer.wrapped(100).filter(row => row.derived && 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), + assert.deepEqual(buffer.wrapped(100).filter(row => row.derived && 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.wrapped(100).some(row => row.derived), 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); + assert.equal(buffer.wrapped(80).some(row => row.derived), 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']); + assert.deepEqual(buffer.wrapped(80).filter(row => row.derived).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'); + assert.equal(buffer.wrapped(80).filter(row => row.derived).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', () => { diff --git a/tests/track.test.ts b/tests/track.test.ts new file mode 100644 index 00000000..fb8e3115 --- /dev/null +++ b/tests/track.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {parseWatch, watchSafety, WatchTasks, MIN_LOCAL_MS} from '../src/tasks/WatchTasks.js'; +import {renderWatchPanel, watchPanelKey, watchRow} from '../src/tasks/WatchPanel.js'; +import {stripAnsi} from '../src/util/text.js'; + +function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + if (predicate()) { resolve(); return; } + if (Date.now() - started > timeoutMs) { reject(new Error('track did not reach expected state')); return; } + setTimeout(tick, 20); + }; + tick(); + }); +} + +test('/track shares the /watch scheduler and parser; it is flagged for its transcript block', () => { + assert.deepEqual(parseSlashCommand('/track df -h /Volumes/T7Projects'), + {kind: 'watch', op: 'start', arguments: 'df -h /Volumes/T7Projects', track: true}); + assert.deepEqual(parseSlashCommand('/track --every 5s -- docker system df'), + {kind: 'watch', op: 'start', arguments: '--every 5s -- docker system df', track: true}); + for (const text of ['/track', '/track list']) { + assert.deepEqual(parseSlashCommand(text), {kind: 'watch', op: 'list', arguments: '', track: true}); + } + assert.deepEqual(parseSlashCommand('/track refresh 2'), {kind: 'watch', op: 'now', arguments: '2', track: true}); + assert.deepEqual(parseSlashCommand('/track pause'), {kind: 'watch', op: 'pause', arguments: '', track: true}); + assert.deepEqual(parseSlashCommand('/track resume 1'), {kind: 'watch', op: 'resume', arguments: '1', track: true}); + assert.deepEqual(parseSlashCommand('/track stop all'), {kind: 'watch', op: 'stop', arguments: 'all', track: true}); + assert.deepEqual(parseSlashCommand('/watch stop 2'), {kind: 'watch', op: 'stop', arguments: '2'}, + 'existing /watch commands must remain compatible'); + assert.deepEqual(parseSlashCommand('/track copy 2'), {kind: 'watch', op: 'copy', arguments: '2', track: true}); + assert.deepEqual(parseSlashCommand('/watch copy'), {kind: 'watch', op: 'start', arguments: 'copy'}, 'copy is a /track word only'); + assert.deepEqual(parseWatch('df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects'}); + assert.deepEqual(parseWatch('--every 5s -- docker system df'), + {command: 'docker system df', intervalMs: 5000}); + assert.deepEqual(parseWatch('--every 2s -- df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects', intervalMs: MIN_LOCAL_MS}); + assert.deepEqual(parseWatch('--every 1500ms -- docker system df'), + {command: 'docker system df', intervalMs: 1500}); + assert.deepEqual(parseWatch('--every 2 minutes -- df -h /Volumes/T7Projects'), + {command: 'df -h /Volumes/T7Projects', intervalMs: 120000}); + for (const invalid of ['--every', '--every 5s', '--every -1s -- df', '--every 1e100s df', '--every 0s df', + '--every 99999999999999999999999999999999999999m df', '--every 61m df', '--every banana df']) { + assert.ok(parseWatch(invalid).error, invalid); + } +}); + +test('/track classifies disk information as read-only, but never assumes Docker writes are safe', () => { + assert.equal(watchSafety('df -h /Volumes/T7Projects').kind, 'allowed'); + assert.equal(watchSafety('docker system df').kind, 'allowed'); + assert.equal(watchSafety('docker system df -v').kind, 'allowed'); + assert.equal(watchSafety('docker system prune').kind, 'confirm'); + assert.equal(watchSafety('docker system df; rm -rf /tmp/x').kind, 'refused'); + assert.equal(watchSafety('docker system df && docker system prune').kind, 'confirm'); + assert.equal(watchSafety('sudo docker system df').kind, 'refused'); + assert.equal(watchSafety('docker system df $(touch /tmp/unsafe)').kind, 'refused'); + assert.equal(watchSafety('df -h /Volumes/T7Projects $(rm -rf /tmp/x)').kind, 'refused'); + assert.equal(watchSafety('df -h `touch /tmp/x`').kind, 'refused'); + assert.equal(watchSafety('docker system df > /tmp/report').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp &').kind, 'refused'); + assert.equal(watchSafety('df -h /tmp < { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-')); + const engine = new WatchTasks('/bin/sh'); + try { + const task = engine.start('sleep 0.3; printf "sample\\n"', cwd, 2000, + {kind: 'allowed', network: false, remote: false}); + assert.equal(engine.runNow(task.id), false, 'a running sample refuses concurrent refresh'); + assert.equal(engine.runNow(task.id), false); + await waitFor(() => task.runs === 1); + assert.equal(task.runs, 1, 'manual refresh must not overlap a running sample'); + assert.equal(task.current?.output, 'sample\n'); + assert.equal(task.previous, undefined, 'no artificial previous sample'); + assert.equal(engine.active().length, 1); + assert.match(stripAnsi(watchRow(task, Date.now())), /Track · sleep 0\.3.*updated/u); + assert.deepEqual(watchPanelKey({selected: 0, output: true}, {kind: 'text', value: 'c'}, [task]), + {kind: 'copy', id: task.id}, 'copy targets exactly the selected tracker'); + assert.match(stripAnsi(renderWatchPanel({selected: 0, output: true}, [task], 100, Date.now(), 20).join('\n')), + /Latest completed output[\s\S]*sample/u); + assert.equal(engine.runNow(task.id), true, 'refresh begins after completion'); + await waitFor(() => task.runs === 2); + assert.equal(task.current?.output, 'sample\n'); + assert.equal(task.previous?.output, 'sample\n'); + assert.equal(task.changes.length, 0); + assert.ok(engine.pause(task.id)); + assert.equal(engine.scheduled, 0); + assert.ok(engine.resume(task.id)); + assert.ok(engine.stop(task.id)); + assert.equal(engine.active().length, 0); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track strips terminal control sequences from the displayed snapshot', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-controls-')); + const engine = new WatchTasks('/bin/sh'); + try { + const task = engine.start('printf "\\033]0;bad-title\\007alpha\\n\\033[31mbeta\\033[0m\\n"', cwd, 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => task.runs === 1); + assert.equal(task.current?.exitCode, 0); + assert.equal(task.current?.output, 'alpha\nbeta\n'); + assert.ok(engine.stop(task.id)); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track bounds retained snapshots, preserves split UTF-8 and records truncation', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-bound-')); + const engine = new WatchTasks('/bin/sh'); + try { + const cmd = `node -e 'process.stdout.write("π"); process.stdout.write("x".repeat(250000))'`; + const task = engine.start(cmd, cwd, 2000, {kind: 'allowed', network: false, remote: false}); + await waitFor(() => task.runs === 1); + assert.equal(task.current?.exitCode, 0); + assert.ok(task.current?.truncated, 'truncation is visible'); + assert.ok(Buffer.byteLength(task.current?.output ?? '') <= 200100, 'output is bounded'); + assert.ok(task.current?.output.startsWith('π'), 'UTF-8 survives split chunks'); + engine.stop(task.id); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track caps concurrent trackers and rejects non-finite intervals', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-limit-')); + const engine = new WatchTasks('/bin/sh'); + try { + assert.throws(() => engine.start('true', cwd, Infinity, {kind: 'allowed', network: false, remote: false}), /interval/u); + const active = Array.from({length: 8}, () => + engine.start('sleep 1', cwd, 5000, {kind: 'allowed', network: false, remote: false})); + assert.equal(engine.active().length, 8); + assert.throws(() => engine.start('true', cwd, 5000, {kind: 'allowed', network: false, remote: false}), /At most 8/u); + for (const task of active) engine.stop(task.id); + await waitFor(() => engine.scheduled === 0); + assert.equal(engine.active().length, 0); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track fails visibly when the command or working directory is unavailable', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'nmsh-track-fallback-')); + const engine = new WatchTasks('/bin/sh'); + try { + const missingCommand = engine.start('nmsh-nonexistent-test-command-393', cwd, 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => missingCommand.runs === 1); + assert.notEqual(missingCommand.current?.exitCode, 0); + const missingCwd = engine.start('true', join(cwd, 'not-here'), 5000, + {kind: 'allowed', network: false, remote: false}); + await waitFor(() => missingCwd.runs === 1); + assert.notEqual(missingCwd.current?.exitCode, 0, 'invalid cwd cannot be a successful refresh'); + assert.match(missingCwd.current?.error ?? '', /ENOENT|spawn/u); + assert.equal(engine.active().length, 2); + } finally { + engine.dispose(); + rmSync(cwd, {recursive: true, force: true}); + } +}); + +test('/track confirmation refuses clipped small-terminal review', () => { + const confirm = {command: 'unknown status-command', cwd: '/tmp', intervalMs: 5000, reason: 'Unknown command', choice: 'no' as const}; + const rows = renderWatchPanel({selected: 0, output: false, confirm}, [], 30, Date.now(), 6); + const text = stripAnsi(rows.join('\n')); + assert.match(text, /Resize to review tracked/u); + assert.doesNotMatch(text, /Yes|confirm/u, 'clipped layouts cannot display approval controls'); +}); + +// ---- v0.21: the live transcript block ------------------------------------------------------------------------------ + +import {finalResultRows, MAX_TRACK_ROWS, trackBlockRows, trackStatus} from '../src/tasks/trackBlock.js'; +import {OutputBuffer} from '../src/output/OutputBuffer.js'; +import {logicalLines} from '../src/output/TranscriptSearch.js'; +import type {WatchTask} from '../src/tasks/WatchTasks.js'; + +const task = (overrides: Partial = {}): WatchTask => ({id: 'watch-x', command: 'df -h', cwd: '/', intervalMs: 5000, startedAt: 0, runs: 0, + status: 'running', changes: [], network: false, ...overrides}); +const run = (output: string, extra: Partial> = {}) => ({output, exitCode: 0, at: 1_000, durationMs: 100, ...extra}); + +test('track status says only what is known: pending, live, failed, timed out, paused, stale, refreshing, stopped', () => { + const plain = (watch: WatchTask, now = 2_000, sampling = false) => stripAnsi(trackStatus(watch, now, sampling)); + assert.match(plain(task()), /^◌ first run pending · every 5s · 0 runs$/u); + assert.match(plain(task(), 2_000, true), /first run…/u); + assert.match(plain(task({current: run('ok'), runs: 1})), /^◉ live · every 5s · updated \d\d:\d\d:\d\d · exit 0 · 1 run$/u); + assert.match(plain(task({current: run('no', {exitCode: 2}), runs: 3})), /exit 2/u); + assert.match(plain(task({current: run('', {timedOut: true, exitCode: null, durationMs: 15_000}), runs: 1})), /timed out after 15s/u); + assert.match(plain(task({current: run('ok'), status: 'paused', runs: 1})), /^‖ paused/u); + assert.match(plain(task({current: run('ok'), runs: 1}), 1_000 + 100 + 16_000), /no new result for 16s/u); + assert.match(plain(task({current: run('ok'), runs: 1}), 2_000, true), /refreshing…/u); + assert.match(plain(task({current: run('ok', {truncated: true}), runs: 1})), /output cut at 200 kB/u); + assert.match(plain(task({current: run('ok'), status: 'stopped', runs: 4})), /^■ stopped/u); +}); + +test('the live result keeps a stable height: shorter results pad, longer ones say how much is not shown; text is inert', () => { + const first = trackBlockRows(task({current: run('a\nb\nc\n'), runs: 1}), 80, 2_000, 0); + assert.equal(first.reserved, 3); + assert.deepEqual(first.rows.slice(1).map(row => stripAnsi(row)), [' a', ' b', ' c']); + const shorter = trackBlockRows(task({current: run('a\n'), runs: 2}), 80, 2_000, first.reserved); + assert.equal(shorter.rows.length, first.rows.length, 'a shorter refresh never moves what is below'); + const long = trackBlockRows(task({current: run(Array.from({length: 40}, (_, index) => `line ${index}`).join('\n')), runs: 3}), 80, 2_000, shorter.reserved); + assert.equal(long.reserved, MAX_TRACK_ROWS); + assert.match(stripAnsi(long.rows.at(-1)!), /… 29 more lines · \/track copy copies them all/u); + const hostile = trackBlockRows(task({current: run('\u001b[2Jclear\u202e!'), runs: 1}), 20, 2_000, 0); + assert.doesNotMatch(hostile.rows.join(''), /\u001b\[2J|\u202e/u); + for (const row of hostile.rows) assert.ok(stripAnsi(row).length <= 20); + assert.deepEqual(finalResultRows(task({current: run(Array.from({length: 5}, (_, index) => `${index}`).join('\n')), runs: 2}), 3).slice(1), + ['0', '1', '2', '… 2 more lines were not kept']); +}); + +test('the block: stored lines once, live rows replaced in place under them, never stored or searched', () => { + const buffer = new OutputBuffer(); + let watch = task({current: run('first\n'), runs: 1}); + let reserved = 0; + buffer.setLiveRows((id, width) => { const drawn = trackBlockRows(id === 'watch-x' ? watch : undefined, width, 2_000, reserved); reserved = drawn.reserved; return drawn.rows; }); + const anchor = buffer.addTrackBlock('watch-x', '/track df -h', ['⎿ Tracking df -h here']); + const stored = JSON.stringify(buffer.transcript().lines); + const before = buffer.wrapped(80); + const live = before.filter(row => row.derived); + assert.deepEqual(live.map(row => row.plain.trim()).slice(1), ['first']); + assert.ok(live.every(row => row.lineIndex === anchor)); + watch = task({current: run('second\n'), runs: 2}); + const after = buffer.wrapped(80); + assert.equal(after.length, before.length, 'one block, replaced in place'); + assert.deepEqual(after.filter(row => row.derived).map(row => row.plain.trim()).slice(1), ['second']); + assert.equal(JSON.stringify(buffer.transcript().lines), stored, 'refreshes store nothing'); + assert.equal(logicalLines(after).some(line => line.text.includes('second')), false, 'find never matches the live result'); + buffer.clearPresentation(); + assert.equal(buffer.wrapped(80).some(row => row.derived), false); +}); diff --git a/tests/trackApp.test.ts b/tests/trackApp.test.ts new file mode 100644 index 00000000..c3834deb --- /dev/null +++ b/tests/trackApp.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {once} from 'node:events'; +import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +async function until(check: () => boolean, what: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(resolve => setTimeout(resolve, 25)); + } +} +const stored = (app: TerminalApp) => (app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('').trimEnd()); +const live = (app: TerminalApp) => app['output'].wrapped(100).filter((row: {derived?: boolean}) => row.derived).map((row: {plain: string}) => row.plain.trim()); + +test('/track in the app: one block whose live result refreshes in place; /track stop leaves the last result once; /watch keeps its manager', {timeout: 60_000}, async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-trackapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + await once(app['session'], 'prompt'); + app['shellCwd'] = root; + writeFileSync(join(root, 'value.txt'), 'one\n'); + app['handleWatch']('/track --every 2s -- cat value.txt', 'start', '--every 2s -- cat value.txt', true); + assert.equal(app['watchPanel'], undefined, '/track shows its result in the transcript, not the manager'); + await until(() => live(app).includes('one'), 'the first result'); + const linesAfterFirst = stored(app).length; + assert.match(stored(app).join('\n'), /\/track --every 2s -- cat value\.txt\n.*Tracking cat value\.txt here with sh -c/u); + writeFileSync(join(root, 'value.txt'), 'two\n'); + await until(() => live(app).includes('two'), 'the refreshed result', 10_000); + assert.equal(live(app).includes('one'), false, 'replaced, not appended'); + assert.equal(stored(app).length, linesAfterFirst, 'refreshes add no transcript lines'); + app['handleWatch']('/track refresh', 'now', '', true); + app['handleWatch']('/track refresh', 'now', '', true); + assert.match(stored(app).join('\n'), /still being produced; no second one was started/u, 'a refresh while one runs is refused, never overlapped'); + await until(() => !app['watches'].sampling(app['watches'].active()[0]!.id), 'the refresh to finish'); + app['handleWatch']('/track stop', 'stop', '', true); + await until(() => stored(app).some(line => line.includes('Stopped · last result')), 'the final result'); + const text = stored(app).join('\n'); + assert.match(text, /Stopped · last result \d\d:\d\d:\d\d · exit 0 · \d+ runs?\n\s*two/u); + assert.equal(text.split('Stopped · last result').length, 2, 'written once'); + assert.match(live(app)[0] ?? '', /^■ stopped/u, 'the live rows end with the stopped state'); + + app['handleWatch']('/watch ls', 'start', 'ls', false); + assert.ok(app['watchPanel'], '/watch opens its manager as before'); + app['watchPanel'] = undefined; + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/trackLive.test.ts b/tests/trackLive.test.ts new file mode 100644 index 00000000..dcf5c600 --- /dev/null +++ b/tests/trackLive.test.ts @@ -0,0 +1,47 @@ +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'; + +/** /track through the real composer and PTY: the live result updates on screen, copy and stop work, history is not spammed. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: /track refreshes one result in place, /track copy copies it, /track stop leaves it once', {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); + writeFileSync(join(sandbox.home, 'value.txt'), 'alpha-1\n'); + let mark = frontend.mark; + frontend.pty.write('/track --every 2s -- cat value.txt\r'); + await frontend.waitFor(/Tracking cat value\.txt[\s\S]*live · every 2s[\s\S]*alpha-1/u, mark); + mark = frontend.mark; + writeFileSync(join(sandbox.home, 'value.txt'), 'alpha-2\n'); + await frontend.waitFor(/alpha-2/u, mark); + // The shell stays usable while the tracker refreshes. + await frontend.run('echo SHELL-STILL-HERE', /SHELL-STILL-HERE/u); + mark = frontend.mark; + frontend.pty.write('/track copy\r'); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8') === 'alpha-2\n', 15_000, 'the latest result on the clipboard'); + mark = frontend.mark; + frontend.pty.write('/track stop\r'); + await frontend.waitFor(/Stopped · last result[\s\S]*alpha-2/u, mark); + await frontend.run('echo AFTER-TRACK', /AFTER-TRACK/u); + // The saved transcript holds NMSh's lines and the final result once, never one copy per refresh. + await until(async () => (await sandbox.transcripts().list()).some(session => JSON.stringify(session.transcript).includes('AFTER-TRACK')), 15_000, 'the journal'); + const sessions = await sandbox.transcripts().list(); + const text = sessions.flatMap(session => (session.transcript.lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join(''))).join('\n'); + assert.equal(text.split('alpha-2').length - 1, 1, 'the final result is stored once'); + assert.equal(text.includes('alpha-1'), false, 'earlier refreshes are never stored'); + } finally { await sandbox.dispose(); } +}); From 53ecbf4e26b4a11e815704c45743f8a9b9791205 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:10:28 +0530 Subject: [PATCH 2/2] /track: a transcript without trackers skips the per-line live-row lookup Refs #393 --- src/output/OutputBuffer.ts | 5 +++-- src/output/TranscriptPresenter.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/output/OutputBuffer.ts b/src/output/OutputBuffer.ts index beca8f74..0b42aabf 100644 --- a/src/output/OutputBuffer.ts +++ b/src/output/OutputBuffer.ts @@ -629,10 +629,11 @@ export class OutputBuffer { firstRun: this.firstRun, ownerOf: this.blockOwnership(), foldHighlights: record => this.foldHighlightsFor(record), - liveRows: (line, width) => { + // Only a transcript with /track blocks pays for the per-line lookup. + ...(this.trackAnchors.size ? {liveRows: (line: number, width: number) => { const id = this.trackAnchors.get(line); return id === undefined ? undefined : this.liveRowsProvider?.(id, width); - }, + }} : {}), semantic: record => { const result = this.semanticFor(record); return result ? {result, open: this.semanticDetailsOpen(record, result)} : undefined; diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index cac7b8ca..e607b181 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -144,7 +144,7 @@ export class TranscriptPresenter { } rows(view: TranscriptView, width: number): WrappedRow[] { - const {lines, completed, active, visualGaps, historicalContexts, welcome, firstRun, ownerOf} = view; + const {lines, completed, active, visualGaps, historicalContexts, welcome, firstRun, ownerOf, liveRows} = view; const column = this.layout === 'chat' ? chatColumn(width) : undefined; // A chat command block is right-aligned as a whole: its widest wrapped row sets the left edge. const blockWidths = new Map(); @@ -288,7 +288,7 @@ export class TranscriptPresenter { result.push(row); } // A /track block's live result, under its stored line (derived: never stored, searched, selected or copied). - for (const ansi of view.liveRows?.(i, width) ?? []) result.push({ansi: truncateAnsi(ansi, width), plain: stripAnsi(truncateAnsi(ansi, width)), lineIndex: i, derived: true}); + if (liveRows) for (const ansi of liveRows(i, width) ?? []) result.push({ansi: truncateAnsi(ansi, width), plain: stripAnsi(truncateAnsi(ansi, width)), lineIndex: i, derived: true}); } if (active) { for (const activity of active.activities) {