Skip to content
Draft
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
12 changes: 12 additions & 0 deletions docs/development/native-appearance-welcome/BACKUPS_AND_RESET.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,15 @@ Manual terminal checks still needed: open /changes, review and restore a harmles
**Interrupted writes (D4).** `outcome` stays `prepared | applied` so older builds still read every entry; how an unfinished write ended is extra evidence in `resolution` (`reverted`, `rollback-incomplete`, `finalized`, `partial`). `Journal.transactionState` reconciles an entry from the bytes on disk, never from assumptions: `applied`, `in-progress` (another live process owns it), `not-applied`, `partially-applied`, `applied-unfinalized`, `conflicted` (a file now matches neither its before nor after image), `reverted`, `rollback-incomplete`, `partial`, `indeterminate` (a file cannot be read safely). An in-process failure records `reverted` or `rollback-incomplete`. In `/changes`, unfinished entries are counted in the notice and marked `!`; Enter reviews a rollback (a normal restore with its own undo checkpoint; only files whose bytes would change and differ from what NMSh wrote need the second confirmation), and `F` marks an entry applied only when every file holds the written bytes (no file is touched). A successful rollback marks the entry `reverted`, naming the undo checkpoint. Unresolved prepared entries are never pruned automatically. Tests kill a real writer process with SIGKILL after the entry is recorded, after the first of two files, and before finalization (`tests/backupsIntegrity.test.ts`).

Limits: the per-host appearance and shader ownership records each describe one file group and are always restored together with that group, so they are not reconciled per tool. PID reuse can make a long-dead writer look `in-progress`; that state blocks nothing but reconciliation advice.

## History, reset and retention (D5–D7, 2026-10-29)

**Readable history (D5).** Each row is `HH:MM <application> · <file> [+N more] · <created|changed|removed|checkpoint> · <status>`, grouped by local day. The application comes from the path (Kitty, Ghostty, Vim colors, Starship, NMSh settings, …); NMSh's own records (settings, ownership JSON) are never named as the subject when a user-facing file is in the entry. The writer's label stays visible as "Recorded as: <source> · <label>". Status distinguishes "changed later by NMSh (<time>)" (the file holds what a newer entry wrote) from "edited since" (something else changed it).

**One reset, one entry (D6).** A single-area reset is one transaction, so its own entry is the undo checkpoint (previously a separate checkpoint duplicated it). A multi-area reset keeps one "Reset checkpoint: A, B" of every file plus "Reset stage i/n: <area>" entries linked to it (`group`), because a later stage can start from a file an earlier stage changed. A failed stage marks the checkpoint `partial` with the per-area results. Navigation steps say what Enter does ("next: backup", "next: type reset", "restore now", "next: confirm overwrite"); only the typed word `reset` writes.

**Scoped settings reset (D7).** `config.json` has three reset areas: NMSh preferences, first-run state (onboarding, intro, tools setup, glyph style and choice) and update policy (`updateMode`, `updateFrequency`). Preferences never touch first-run state or the update policy; no scope touches the user's themes, `customTheme`, welcome presets, agent profiles, or fields this version does not know. Selected scopes are planned together as one exact change. The impact lists every changed setting as `key: before → after` and what is kept. A missing settings file is "nothing to reset"; malformed JSON is refused, never guessed at; a second reset is a no-op.

**Retention guarantees.** The journal keeps at most 200 entries / 64 MiB (unchanged). Never pruned automatically: unfinished entries (unresolved, or rollback-incomplete), the newest entry per source and file, and the 20 newest reset/restore/manual entries. Pruned oldest-first: every other entry, except each file's earliest kept backup ("original"); originals go only when nothing else can, oldest first, and each removal is recorded (path and time, newest 50) in `.pruned.json` and shown in `/changes` as a retention warning that suggests a manual snapshot. If even the never-pruned entries do not fit, the write is refused with that reason. So NMSh does not promise that the state before its first change is kept forever: it keeps it ahead of everything automatic and says when capacity pressure removed it. "Original" means the earliest backup the journal still has; files NMSh changed before the journal existed (builds before c3620236) have no pre-NMSh copy. A settings reset that keeps the update policy or first-run state writes out absent keys as their current effective value, so a legacy file cannot change meaning. Tested: 260 automatic writes to one file beside two edits of another keep both originals within 200 entries; a three-entry cap reports the original it removed.

Known cost: each journaled write lists and verifies the whole history (~0.4 s per write at 200 entries on this Mac); bursts of automatic shader writes pay it each time. Not changed here.
11 changes: 11 additions & 0 deletions docs/development/native-appearance-welcome/QA_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,16 @@ Automated tests use private HOMEs, headless host parsers and proxy compiles. Non
6. Export a preset. The file has no image path unless you opted in, and never contains image bytes. Import it again.
7. On a host without verified inline graphics (for example Terminal.app), the image falls back to cell art with the reason shown.

## Backups, /changes and /nmsh reset (current build; headless-covered, physical NOT RUN)

Use a disposable HOME/XDG_CONFIG_HOME (see `scripts/qa/` fixtures); never the real configuration.
1. Launch NMSh three times with Theme Bridge on. `theme-bridge/ledger.json` keeps its bytes and inode; `/changes` gains no entries and older entries stay "unchanged since".
2. `/changes`: rows read `HH:MM <Application> · <file> · <created/changed/removed> · <status>`; the details show the full path, local time and the original label as "Recorded as". A file NMSh rewrote later says "changed later by NMSh", not "edited since".
3. Restore an older Theme Bridge entry while a newer Vim file is managed: the review says the Vim record is kept; afterwards `/theme-bridge` still manages Vim.
4. Replace a recorded config directory with a plain file: other appearance changes still save; that entry's row says "unsafe file"; Enter names the entry, the path and the reason, and changes nothing.
5. Footers: restore review says "Enter restore now" (or "next: confirm overwrite"); reset Impact says "Enter next: backup", Backup says "Enter next: type reset"; only the typed word writes.
6. `/nmsh reset` → "NMSh preferences": the impact lists `key: before → after` and "Kept: first-run state …, update policy …"; after the reset, onboarding does not reappear and `updateMode` is unchanged. Undo through `/changes`.
7. Check every step at 80×24 and 60×20, with NO_COLOR=1.

## Record
Note the host, version, OS, and pass/fail per item. Attach screenshots for anything visual. Report anything that writes outside NMSh's own files.
47 changes: 36 additions & 11 deletions src/backups/ChangesPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import {dirname,join} from 'node:path';
import type {Key} from '../terminal/keys.js';
import {renderStudioLayout, renderStudioReview, studioWrap, type StudioReview} from '../ui/StudioLayout.js';
import {Journal, type JournalEntry, type TransactionReport} from './journal.js';
import {describeState, needsReview} from './describe.js';
import {applicationOf, describeEntry, describeState, displayPath, fileAction, localDay, needsReview} from './describe.js';
import {digest, snapshot} from '../appearance/hostFiles.js';
import {manualSnapshot, planRestore, restore, type RestorePlan} from './restore.js';
export interface ChangesPanel {
journal:Journal;ownershipPaths:string[];entries:JournalEntry[];statuses:Map<string,string>;states:Map<string,TransactionReport>;
selected:number;view:'list'|'review'|'confirm'|'result';review?:StudioReview;restore?:RestorePlan;message:string;shown:boolean;
}
export function createChangesPanel(journal:Journal,ownershipPaths:readonly string[]):ChangesPanel {
const state:ChangesPanel={journal,ownershipPaths:[...new Set([join(dirname(journal.directory),'config.json'),...ownershipPaths])],entries:[],statuses:new Map(),states:new Map(),selected:0,view:'list',message:'History is capped at 200 entries / 64 MiB; newest 20 protected checkpoints and source/path anchors are kept.',shown:false};
const state:ChangesPanel={journal,ownershipPaths:[...new Set([join(dirname(journal.directory),'config.json'),...ownershipPaths])],entries:[],statuses:new Map(),states:new Map(),selected:0,view:'list',message:RETENTION,shown:false};
refresh(state);return state;
}
function refresh(state:ChangesPanel):void {
Expand All @@ -19,10 +20,19 @@ function refresh(state:ChangesPanel):void {
// A damaged entry is kept on disk for review and named with its reason; it no longer blocks other backups or writes.
const damaged=[...state.journal.unreadableReasons];
if(damaged.length)notices.push(`${damaged.length} unreadable backup ${damaged.length===1?'entry needs':'entries need'} review and ${damaged.length===1?'is':'are'} not listed: ${damaged.slice(0,3).map(([id,reason])=>`${id}: ${reason}`).join('; ')}${damaged.length>3?'; …':''} (kept in ${state.journal.directory}).`);
try {const count=state.journal.prunedCount();if(count)notices.push(`${count} old backup entries pruned.`);}catch{notices.push('Backup pruning record unreadable.');}
try {
const count=state.journal.prunedCount();if(count)notices.push(`${count} old backup entries pruned.`);
const lost=[...new Map(state.journal.lostOriginals().map(item=>[item.path,item])).values()];
if(lost.length)notices.unshift(`Retention warning: to stay within the backup limits, the earliest backup of ${lost.length} file${lost.length===1?' was':'s were'} removed: ${lost.slice(-3).reverse().map(item=>`${displayPath(item.path,state.journal.options.home)} (from ${new Date(item.time).toLocaleString()})`).join('; ')}${lost.length>3?'; …':''}. Press S to keep a snapshot of the current files.`);
}catch{notices.push('Backup pruning record unreadable.');}
const failure=takeSettingsCheckpointFailure(state.journal.directory);if(failure)notices.push(failure);
for(const entry of state.entries){
for(const file of entry.files){const seen=state.journal.inspect(file);state.statuses.set(`${entry.id}:${file.path}`,seen.reason?`unsafe: ${seen.reason}`:seen.status);}
for(const file of entry.files){
const seen=state.journal.inspect(file);
// "Edited since" only when something other than NMSh changed it: a later NMSh write is named as such.
const later=seen.status==='edited since'&&laterWrite(state,entry,file.path);
state.statuses.set(`${entry.id}:${file.path}`,seen.reason?`unsafe: ${seen.reason}`:later?`changed later by NMSh (${later})`:seen.status);
}
state.states.set(entry.id,state.journal.transactionState(entry));
}
const unfinished=state.entries.filter(entry=>needsReview(state.states.get(entry.id)!.state)).length;
Expand Down Expand Up @@ -50,7 +60,7 @@ export function changesKey(state:ChangesPanel,key:Key):{close?:true;paths?:strin
}
if(key.kind==='enter'&&state.entries[state.selected]){
state.restore=planRestore(state.journal,state.entries[state.selected]!.id);
state.review={plan:state.restore,offset:0};state.view='review';state.shown=false;
state.review={plan:state.restore,offset:0,controls:[['↑↓','scroll diff'],state.restore.edited.length?['Enter','next: confirm overwrite']:['Enter','restore now'],['Esc','cancel']]};state.view='review';state.shown=false;
}
}else if(state.view==='review'){
if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1));
Expand Down Expand Up @@ -83,17 +93,21 @@ export function renderChangesPanel(state:ChangesPanel,columns:number,height:numb
if(state.view==='confirm')return renderStudioReview(state.review!,'Confirm loss of later edits',columns,height);
if(state.view==='result')return renderStudioReview(state.review!,'Changes · result',columns,height);
return renderStudioLayout({title:'Changes & backups',subtitle:'Newest first · Enter reviews a restore · S snapshots known managed files',
entries:state.entries.length?state.entries.map(item=>({group:item.time.slice(0,10),label:item.label,
entries:state.entries.length?state.entries.map(item=>({group:localDay(item.time),label:item.label,
render:width=>{
const states=item.files.map(file=>state.statuses.get(`${item.id}:${file.path}`));
const states=item.files.map(file=>state.statuses.get(`${item.id}:${file.path}`)?.replace(/ \(.*$/u,''));
const described=describeEntry(item,configDirectory(state));
const report=state.states.get(item.id);
const summary=report&&report.state!=='applied'?`${needsReview(report.state)?'! ':''}${describeState(report.state)}`
:states.includes('edited since')?'edited since':states.includes('missing')?'missing':states.some(value=>value!=='unchanged since')?'unsafe file':'unchanged since';
return studioWrap(`${item.time.slice(11,16)} ${item.source} · ${summary} · ${item.files.length} files · ${item.label}`,width)[0]!;
:states.includes('edited since')?'edited since':states.includes('missing')?'missing':states.some(value=>value?.startsWith('unsafe'))?'unsafe file'
:states.includes('changed later by NMSh')?'changed later by NMSh':'unchanged since';
return studioWrap(`${described.when} ${described.what} · ${described.action} · ${summary}`,width)[0]!;
}})):[{group:'History',label:'No backups yet'}],
selected:state.selected,notice:state.message,details:(width,budget)=>entry?[...studioWrap(`${entry.time} · ${entry.source} · ${entry.label}`,width),
selected:state.selected,notice:state.message,details:(width,budget)=>entry?[
...studioWrap(`${describeEntry(entry,configDirectory(state)).what} · ${new Date(entry.time).toLocaleString()} · ${entry.id.slice(0,15)}`,width),
...studioWrap(`Recorded as: ${entry.source} · ${entry.label}`,width),
...stateDetails(state,entry).flatMap(line=>studioWrap(line,width)),
...entry.files.flatMap(file=>studioWrap(`${file.path}: ${state.statuses.get(`${entry.id}:${file.path}`)}`,width))].slice(0,budget):[],
...entry.files.flatMap(file=>studioWrap(`${displayPath(file.path,state.journal.options.home)} · ${applicationOf(file.path,configDirectory(state))} · ${fileAction(file)} · ${state.statuses.get(`${entry.id}:${file.path}`)}`,width))].slice(0,budget):[],
controls:[['↑↓','select'],['Enter','review restore'],['S','snapshot now'],...(entry&&state.states.get(entry.id)?.state==='applied-unfinalized'?[['F','mark applied'] as const]:[]),...(entry&&needsReview(state.states.get(entry.id)?.state??'applied')?[['D','set aside'] as const]:[]),['Esc','close']]},columns,height);
}

Expand All @@ -109,3 +123,14 @@ function stateDetails(state:ChangesPanel,entry:JournalEntry):string[] {
lines.push(...report.reasons);
return lines;
}

const configDirectory=(state:ChangesPanel)=>dirname(state.journal.directory);
/** What history keeps, stated as the pruner implements it (see docs/development/native-appearance-welcome/BACKUPS_AND_RESET.md). */
export const RETENTION='Keeps up to 200 entries / 64 MiB: each file\'s earliest backup, unfinished changes, the newest backup per file and the 20 newest resets, restores and snapshots. Anything else oldest-first; a removed earliest backup is reported here.';
/** When the file now holds exactly what a newer NMSh entry wrote, the local time of that entry. */
function laterWrite(state:ChangesPanel,entry:JournalEntry,path:string):string|undefined {
let now:string;
try {const current=snapshot(path,state.journal.options.home);now=current.content===undefined?'absent':digest(current.content);}catch{return undefined;}
const newer=state.entries.find(other=>other.time>entry.time&&other.files.some(file=>file.path===path&&file.afterHash===now));
return newer?new Date(newer.time).toLocaleString():undefined;
}
9 changes: 5 additions & 4 deletions src/backups/ResetPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ export function resetKey(state:ResetPanel,key:Key):{close?:true;paths?:string[]}
if(key.kind==='up'||key.kind==='down')state.index=Math.max(0,Math.min(state.areas.length-1,state.index+(key.kind==='up'?-1:1)));
if(key.kind==='text'&&key.value===' '){const id=state.areas[state.index]!.id;if(state.selected.has(id))state.selected.delete(id);else state.selected.add(id);}
if(key.kind==='enter'&&state.selected.size){
state.plan=planReset(state.areas,state.selected,state.context);state.review={offset:0,plan:{preview:state.plan.preview,guidance:'Only recorded NMSh changes are reset. User lines are kept. Enter advances to Backup; nothing is written.'}};
state.plan=planReset(state.areas,state.selected,state.context);state.review={offset:0,plan:{preview:state.plan.preview,guidance:'Only recorded NMSh changes are reset. User lines are kept. Enter advances to Backup; nothing is written.'},controls:[['↑↓','scroll'],['Enter','next: backup'],['Esc','cancel']]};
state.step='impact';state.shown=false;
}
}else if(state.step==='impact'){
if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1));
if(key.kind==='enter'&&state.review?.shown){state.step='backup';state.shown=false;state.review={offset:0,plan:{preview:[`Backup directory: ${state.journal.directory}`, 'Includes files changed by the reset and their ownership records.',
...state.plan!.changes.map(change=>change.before.path)],guidance:'Backup must be written, read back and hash verified before any reset. Enter advances to final typed confirmation.'}};}
...state.plan!.changes.map(change=>change.before.path)],guidance:'Backup must be written, read back and hash verified before any reset. Enter advances to final typed confirmation; nothing is written yet.'},controls:[['↑↓','scroll'],['Enter','next: type reset'],['Esc','cancel']]};}
}else if(state.step==='backup'){
if(key.kind==='up'||key.kind==='down')state.review!.offset=Math.max(0,state.review!.offset+(key.kind==='up'?-1:1));
if(key.kind==='enter'&&state.review?.shown){state.step='confirm';state.shown=false;state.message='Review complete. Type reset exactly, then Enter; Esc cancels.';}
Expand Down Expand Up @@ -61,8 +61,9 @@ export function renderResetPanel(state:ResetPanel,columns:number,height:number):
controls:[['Enter','reset if word matches'],['Esc','cancel']]},columns,height);
if(state.step==='result')return renderStudioReview(state.review!,'Reset · results',columns,height);
return renderStudioLayout({title:'Step 1 · Choose reset areas',subtitle:'Space toggles · nothing is selected by default',selected:state.index,
entries:state.areas.map(item=>({group:item.id==='settings'?'NMSh settings':'Owned integrations',label:item.label,
entries:state.areas.map(item=>({group:item.settingsScope?'NMSh settings':'Owned integrations',label:item.label,
render:()=>`${checkMark(state.selected.has(item.id))} ${item.label} · ${item.status}`})),
notice:state.message,details:width=>studioWrap(state.areas[state.index]?.reason??'Only NMSh-owned changes are offered; unrelated user lines are kept.',width),
notice:state.message,details:(width,budget)=>{const item=state.areas[state.index];
return (item?.reason?[item.reason]:item?.summary??['Only NMSh-owned changes are offered; unrelated user lines are kept.']).flatMap(line=>studioWrap(line,width)).slice(0,budget);},
controls:[['↑↓','select'],['Space','toggle'],['Enter','impact'],['Esc','cancel']]},columns,height);
}
Loading
Loading