Repository navigation
v0.8.24
Fixed
-
tstyles uninstallsilently corrupted a$PROFILEthat contained a byte which is not valid UTF-8, and took no backup on the way out. Step 3 of uninstall reads the whole of the user's$PROFILEand writes the whole of it back, exactly as the rc half does -- and it did so through UTF-8.Get-RcFileEncodingexists because of what that costs, and its own docstring spells it out: a latin-1 comment or a stray byte from an old editor decodes to U+FFFD and is written back as the replacement character, so "the user's own content, silently and permanently corrupted, by a tool that was only asked to append three lines". That was fixed for.zshrcand.bashrc; the$PROFILEhalf was a second, open-coded copy of the same removal and never got it. Measured on a profile whose first line was# café, bytee9:The profile is now imported once and reused: a window is opened on the EXISTING settings set through AppleScript rather than by re-importing the file. That is the whole fix for the accumulation, but on its own it would have traded a duplicate for something worse -- a re-tuned style would open a window in the colours it no longer has, because the installed copy is not refreshed by regenerating the file. So an import record (
.appleterminal-imported.json, the same idea as.installed-files) maps each profile name to the SHA256 of the.terminalfile that produced it: unchanged, the installed profile is reused; changed, TerminalStyles deletes ITS copy and re-imports, which also keeps the new import from colliding with a name and being numbered.The record is what makes the delete safe. Without one there is no way to tell a settings set this tool created from one the user made and happened to name after a style, and deleting the wrong one cannot be undone -- so a profile with no record is never touched, and gets the old numbered-copy behaviour instead. Every path falls back to
openwhen Terminal.app cannot be asked, so nothing here can make the feature worse than it was.Changes go through AppleScript rather than by writing
~/Library/Preferences/com.apple.Terminal.plist: Terminal.app holds its preferences in memory and rewrites that file when it quits, so an external write to a running Terminal is silently reverted. -
tstyles uninstallthrew before it could ask for consent, on any machine, because two lines were lost in a merge.$wezSplat = @{}and theContainsKey('HomeDir')line beside it were dropped resolving the merge between the WezTerm writer and the$PROFILE-strip change -- both touched the same few lines at the top ofInvoke-TerminalStylesUninstall-- while both USES of@wezSplatsurvived further down.mainwent red on the merge commit.The mechanism is worth recording, because it is the opposite of what everything in this project assumes. PowerShell does not object to splatting a variable that does not exist, and it does not pass "no arguments" either:
function Probe { param([string]$HomeDir) ... } Probe @neverDefined -> bound=True value=[]It binds the FIRST POSITIONAL parameter to an empty string, and
$PSBoundParameters.ContainsKey('HomeDir')reports$true. ThatContainsKeytest is the guard this codebase uses everywhere to tell "the caller asked for a sandbox" from "use the live$HOME" -- so a lost splat definition does not turn the seam off, it announces a sandbox at the empty path. It fails unsafe. Here it happened to throw onJoin-Path, which is why five tests went red rather than something quietly resolving against the wrong home; that was luck.tests/Splat-Variables-Defined.Tests.ps1now walks the AST of every source file and fails if any scope splats a variable it never assigns. It names the offending function and variable, and it catches this exact regression when the fix is reverted. -
tstyles shell-initdeleted the user's own lines out of~/.zshrc, reportedupdatedin cyan, and left nothing to get them back from. The refresh path matchesBEGIN .*? ENDwithSinglelineand writes the loader block over whatever it matched. A lazy.*?is lazy in the END, not in the BEGIN: the match still starts at the FIRST BEGIN in the file and runs to the first END anywhere after it. So an rc file carrying a stray or duplicated BEGIN -- a hand edit, a merged dotfile, an interrupted write, the inputsUnregister-ShellLoader's own docstring already names as things it must survive -- had every one of the user's lines between that marker and the real block replaced by our three. Measured on a.zshrcwhose stray marker sat aboveexport MY_API_KEY=...andexport PATH="$PATH:/opt/mine": both gone, and no.zshrc.bak-*anywhere on disk. That second half is not an oversight:Save-FirstTouchBackupis called on the APPEND branch, and skips a file that already carries our block on purpose, because a copy taken then would capture a file that carries it. Which left the one path in this project that can destroy content the user wrote as the one path that never takes a copy.And it was never one command.
Unregister-ShellLoaderbuilds the same span, sotstyles shell-remove-- andtstyles uninstall, which strips the rc files and both$PROFILEfiles through it as of the entry below -- deleted those same lines and returnedremoved. Itsmalformedguard cannot catch this input:$stripped -eq $contentfires only when there is no END ANYWHERE, and a stray BEGIN above a well-formed block matches perfectly well.tstyles registerandinstall.ps1keep the third and fourth copy of the pattern, for the$PROFILE, where the strip replaces with NOTHING rather than with the block -- so there the lines were not overwritten but simply removed, on a branch that takes no backup for the same first-touch reason. All four spans are now TEMPERED: they may not cross a second BEGIN, so the match fails at the stray marker, starts again at the real one, and the only bytes that can change are the ones between two markers this tool wrote. The stray marker is left exactly where it was, because it is the user's line now. -
one style with a malformed
scheme.jsonkilled baretstylesoutright for every zsh and bash user, and quietly showed everyone else the PREVIOUS style's colours in its place. The picker pre-loads every style's scheme before it draws the menu, and that read was the one parse left with nothing around it:tstyles listhas degraded a bad folder to an(unreadable scheme.json)row since 0.8.10, and thetheme.jsonpre-load twelve lines below this one is inside a try/catch.Get-AvailableStylesadmits a folder onscheme.jsonEXISTING, never on it parsing, so a hand-authored style with a JSON typo -- README invites them in that directory -- or atstyles tunesave killed mid-write (Save-TunedStylewrites the file with a plain, non-atomicWriteAllText) arrives at that loop as an ordinary entry. What it cost then split on$ErrorActionPreference, and both halves are ordinary:Stop, which is what the generatedtstyles-cli.ps1sets and whatshell/tstyles.shruns for everytstylescall from zsh and bash: thereConvertFrom-Json's error is fully terminating, so the flagship command -- the one the README leads with -- died with a rawConversion from JSON failed with error: Unterminated string, naming a line oftstyles.ps1and never the style, and drew no menu at all, on every invocation until the folder was found and repaired by hand.tstyles listin the same state kept working and even flagged the culprit, which made the picker's death look like an unrelated bug.Continue, pwsh's default: the error is only statement-terminating, so the loop carried on -- with$schemestill holding the previous style's object. The broken style took the previous style's swatch, the previous style's live OSC preview, and on Enter the previous style's palette written intocurrent-style.oscunder the broken style's name, which every new zsh/bash tab then replays. Measured: pickingmythemestagedmarquee's palette verbatim (fg#f0c8d8, bg#000000, cursor#ff4090) whilecurrent-style.jsonrecorded"name": "mytheme", with nothing on screen after the first red block to say so.
The parse now happens exactly once, in
Get-PickerStyleSet, and a style that fails it is not in the picker's set at all.$idx,$startIdx,$swatches,$schemes,$titles,$oscPacketsand$mergedCacheare all keyed by position, so keeping the entry with a null scheme only moves the crash into the draw path -- which is what a malformed style sorting FIRST already did, asCannot bind argument to parameter 'Scheme' because it is nullon the picker's own first frame. Dropping it in silence would be the other half of the same defect, so the menu carries a line naming what is missing and why, inside the frame: everything printed above the menu is wiped by the picker's ownClear-Host, which is why the update notice is held back too. An emptyscheme.json-- the other shape a truncated write leaves -- counts as unreadable rather than as a scheme, sinceConvertFrom-Jsonreturns$nullfor it on pwsh 7 instead of raising, and "it did not throw" was never the test. -
Esc stopped re-emitting a style the user was never in. "Did the user arrive in a style" was
[bool](Get-CurrentStyleName), but that name can be one the menu does not carry -- a style whosescheme.jsonjust failed the parse above, or one recorded and since deleted -- and the revert then re-emitted index 0, which is some OTHER style's palette, while telling the user it was putting back what they had. It is now the index or nothing, and an unknown one falls back to handing colour control to the terminal, as it already did for a session that arrived unstyled. -
every style deleted the user's own PSReadLine key bindings, on Windows, on every new tab. All sixteen
styles/*/profile.ps1calledSet-PSReadLineOption -EditMode Windowsbehind a Windows-only guard. The comment above it in all sixteen files, and 0.8.21's CHANGELOG entry, both said this was costless because Windows mode is already the default there. Both were wrong. Supplying-EditModeat all makes PSReadLine throw away its dispatch tables and rebuild them from that mode's defaults -- the value is never compared against the current mode -- so everySet-PSReadLineKeyHandlerthe user had made in that session was deleted. Measured on PSReadLine 2.4.5 with the session already in Windows mode, so the call changed nothing:start : Alt+j=CustomAction; Ctrl+w=ForwardWord total=65 after other shipped calls: Alt+j=CustomAction; Ctrl+w=ForwardWord total=65 after -EditMode Windows : Ctrl+w=BackwardKillWord total=63The module is imported from the END of
$PROFILE, so the style always ran after the user's own bindings and the user always lost. 0.8.21 fixed the Unix half of this same statement -- where the switch additionally unbinds Ctrl+D, Ctrl+U, Ctrl+E and Ctrl+K -- and left the Windows half standing on the strength of that comment.The statement is DELETED rather than guarded. On Windows it has exactly two reachable effects: erase the keymap and change nothing else, or erase the keymap while overriding a Vi or Emacs mode the user deliberately chose. A read-then-write guard removes only the first and leaves the second, which is the case a colour theme has least business touching. The other three
Set-PSReadLineOptioncalls in those files ---PredictionSource,-PredictionViewStyleand-Colors-- were measured to leave all 65 handlers intact and are unchanged.The lint that was supposed to guard this matched the file's TEXT, and the shipped comment satisfied it; the replacement walks the AST for a
Set-PSReadLineOptioncarrying an-EditModeparameter, and is joined by a runtime test that binds a key in a child engine, dot-sources the style, and asserts the binding survived. -
one failed file copy during an install or update deleted the install, and the message said nothing had changed.
Sync-InstallTreeran a removal loop over every install-owned entry to completion, and only then a copy loop.install.ps1's main body runs under$ErrorActionPreference = 'Stop'inside atry/finallywith nocatch, so the FIRST failingCopy-Itemterminated the installer with the module already deleted.Assert-InstallLanded, whose entire job is to say "the module is not there", never ran, because the throw precedes it -- and so didWrite-InstallManifest, leaving.installed-filesstale against the tree on disk, which is what uninstall reads to decide what it owns.A full disk, or an antivirus holding a freshly-extracted file open, was enough. The data root came out with no
TerminalStyles.psd1while the$PROFILEloader still pointed at it, so every new tab opened on a red "no valid module file was found" with notstylescommand -- from a session that had been working seconds earlier, and aftertstyles updatehad printed "Update failed: . You can retry manually", which reads as "nothing was changed". Measured against the shipped function with the source file locked:TerminalStyles.psd1was gone from the install directory afterwards.Everything is now copied into a staging directory FIRST, and only then swapped in by rename. The long, failure-prone half -- reading the download and writing bytes to a volume that can fill up -- is entirely non-destructive; what is left destructive is renames, which read nothing. Staging is a sibling of the install directory so the swap stays on one volume, at the cost of both trees occupying it at once: a disk too full for both now fails during staging, with the working install still in place, which is the direction to fail in. The function also pins
$ErrorActionPreferencelocally, because staging is worth nothing if a failed copy does not stop.The two failure phases now say different things, because "your install is fine, retry" and "your install is half-replaced" are opposite instructions and the caller prints the same "You can retry manually" under both. A staging failure says
Nothing in '<dir>' was removed or replaced.; a swap failure says the installmay now be incomplete -- re-run the installer to finish it. The swap is not atomic and is not claimed to be. -
a tune.json the tuner could not read dropped the style's recorded adjustments and said nothing, and saving from there severed its lineage for good.
Resolve-TuneSeedreportsBaseMissingwhen the base is named and gone, andBaseChangedwhen it is there and re-baked, so the tuner can explain why the knobs are not where the user left them. A tune.json that could not be USED took neither flag: a zero-length or whitespace-only file, a bare scalar or array,{}, abasewritten asnullor"", a delta that is not a number, a file that is not JSON at all, and a read that threw on a lock or a permission denial all landed in an emptycatchor fell past the lastif. The tuner opened on brightness 0 / saturation 0 as though the style had never been tuned, with every notice flag at its default.The loss then became permanent on the next save:
Save-TunedStyle'sif (-not $LineageBase) { $LineageBase = $BaseName }writes the style as its OWN base, andGet-TunedBaseBackground's self-reference guard returns$nullfor ever after. It is the same silence the deleted-base and drifted-base notices were added to close, arriving through a third door.TuneUnusableis that third state rather than a shade of the other two, because there is no base name to quote and no lineage to carry forward -- reusing theBaseChangedbranch would have printed'' is gone. The tuner says so before a key is pressed, and says the harder part out loud: saving now records this style as its own base. Thecatchthat swallowed the whole class for the life of the seed object now sets the flag rather than nothing.Narrower than first reported, and worth recording accurately: opacity and font come back CORRECTLY in this state, because the fallback reads the style's own
theme.json, which the tuner itself wrote, and the tuned colours are already baked intoscheme.json. What is actually lost is the record of the two deltas, and then the lineage. -
applying a style could delete a background the user had set themselves, decided by the directory the shell happened to be in.
Test-ManagedBackgroundPathis the ownership proof in front of a destructive edit tosettings.json, and it answered with[System.IO.Path]::GetFullPath($Path).StartsWith(<root>).GetFullPathresolves a non-rooted string against the PROCESS WORKING DIRECTORY, so with the process started inside the module root, the data root, or a...\Modules\TerminalStylesparent, Windows Terminal's owndesktopWallpaperkeyword normalised to<cwd>/desktopWallpaper, matched the root, and was reported as ours.Merge-StyleIntoSettingsthen tookbgAction='remove'and strippedbackgroundImagealong with its opacity, stretch mode and alignment. README promises the opposite in as many words, and the comment inside the function stated the false premise verbatim -- that a WT keyword "resolves against the CWD and so never lands under one of our roots".apply.ps1's own usage header documentscd TerminalStyles; pwsh -File .\apply.ps1 ..., and a child pwsh inherits the parent's location as its working directory, so this is reachable exactly as written.Every background this module writes is
Join-Pathed onto an absolute root, so a value that is not an absolute path is one it could not have written. The function now says so before consulting the roots, which covers the keyword, a relative path and an environment-variable form like%USERPROFILE%\Pictures\bg.pngin one rule -- and a second line rejects the Windows drive-relativeC:bg.png, whichIsPathRootedcalls rooted but which resolves through that drive's own current directory. Environment variables are deliberately NOT expanded: expanding would invent a path on which the tool then deletes the user's data, to buy nothing, since it never writes that form. -
under
set -uthe shell runtime printed unbound-variable errors on every prompt and applied no style at all.shell/tstyles.shis sourced from the user's rc file on EVERY interactive shell, so it is the one file in this project guaranteed to run for every zsh and bash user -- and it read its ambient variables bare. Withnounsetset, which plenty of people put in their rc, sourcing it failed at the first read and never definedts_c. Measured in a real bash underset -eu:TSTYLES_DATA: unbound variable, thenZSH_VERSION,_ts_shelltwice,_ts_loaded, thents_c: command not foundthree times, the banner printing anyway, and the shell falling back to bash's own defaultPS1-- eleven lines of error per tab, and no style. Every bare ambient read now uses the POSIX default-expansion form.$HOMEis deliberately left bare. bash does not synthesise it when the environment lacks one, so${HOME-}would turn a loud failure into the silently wrong absolute path/Library/Application Support/TerminalStyles-- and a bash with noHOMEcannot find the~/.bashrcthat sources this file in the first place, so the loader never runs on that path. A loud failure on an unreachable path beats a silent wrong answer.$TS_GIT_OPEN/$TS_GIT_CLOSEwere hardened although only gitbash defines them and it defines them first: the runtime owns{GITBRANCH}while one style owns its colours, which is the two-implementations-of-one-rule shape this file keeps being bitten by. An uncoloured branch name is a better answer than an error inside the prompt of every command.set -ewas measured and is NOT a problem, before the fix as well as after: the path already guards with[ -t 1 ] || return 0,_ts_b=$(...) || return 0and an explicitreturn 0after acasewhose body can end false. The only errexit interaction that mattered was combined with nounset, and the new tests assert underset -eurather thanset -ualone. -
running the test suite rewrote the shell shim inside the developer's own TerminalStyles install, and on a bootstrap install that left every new zsh tab without a working
tstyles.tests/FirstTouch-Backup.Tests.ps1passed-HomeDirand nothing else.-HomeDirsandboxes the rc lookup;Invoke-TerminalStylesShellInitreachesSync-ShellRuntimebefore it registers anything, and that stagesshell/tstyles.shand regenerateststyles-cli.ps1under the DATA ROOT -- the path-HomeDirdoes not reach, and the one function CLAUDE.md names as having no seam at all. Four calls in that file, four rewrites of the operator's live state per run. Which import line the shim gets comes fromGet-TerminalStylesInstallKind, which asks whether the module root IS the data root; under the tests the module root is the working tree, so it always answersPSResourceGetand writes the by-nameImport-Module TerminalStyles. A bootstrap install is not on$env:PSModulePath-- the entire reason the generated shim bakes in an absolute path -- so the nexttstylesfrom zsh died with "The specified module 'TerminalStyles' was not loaded because no valid module file was found in any module directory". That is the 0.8.21 defect verbatim, delivered this time by a green 7/7 test run rather than byregister. It has already fired on the machine this was found on: the installedtstyles-cli.ps1there is byte-for-byte the file the test produces, on a bootstrap install whose correct shim carries the absolute path, andtstylesfrom zsh had been silently running 0.8.21 out of a stale PSGallery copy ever since.tests/Merge-StyleIntoSettings.Tests.ps1was going through the same hole by a different door. Its fixture style is namedeva, and merging a style with no-BackgroundImagemakes the merge resolve one itself, whose third tier lazy-FETCHES from thegifsbranch into<data root>/cache/<name>-- so an ordinary suite run downloaded the real 3 MBeva.gifinto the operator's cache. Both files now sandbox the data root the way the siblings calling the same entry points already did.The guard itself had the defect it exists to catch, and it was found by reverting the fix and watching it report green. Its baseline sat in the guard file's own
BeforeDiscovery, on the documented Pester 5 behaviour that every container is discovered before any is executed. Pester 6 does not do that: it discovers and executes each file in turn, so a guard file that sorts LAST photographed the state AFTER every other file had already run. The baseline contained the damage and the comparison compared it against itself..github/workflows/test.ymlinstalls Pester with[5.0.0,), so CI legs run whichever major is current. The baseline now lives intests/Aa-RealStateBaseline.Tests.ps1, which sorts first and asserts that it does, and the guard fails the run naming the file and the timestamps when anything under the data root, the rc files or$PROFILEmoves. -
tstyles <style> -NewWindowadded a permanent Terminal.app profile every single time it ran.Open-AppleTerminalProfilewas& open $Path, andopenon a.terminalfile does not UPDATE a profile of the same name -- it imports a second one, which Terminal.app resolves by appending a number. So the command documented as "a new window with the background image as well" also grew the user's profile list, silently, once per run, for the life of the install. Found on a machine with nine of them:eva,eva 1,eva 2,eva 3,umbrella,umbrella 1,felitest,neon-rain,shorty-- four evas from four-NewWindowruns. Nothing deduplicated, nothing updated in place, andtstyles uninstalldoes not remove them either, because it promises in as many words not to touch Terminal.app's settings. Neither did anything mention that the command wrote to Terminal.app's profile list at all. -
the guard that every theme's swatch is distinct compared zero pairs of themes, and reported green for its whole life.
$themeNameswas built in the file-levelBeforeDiscovery, which Pester runs in the discovery scope, so inside anItbody it is$null.@($null)is a ONE-element array, so the outer loop ran a single iteration and the innerfor ($j = 1; $j -lt 1; ...)never entered.InModuleScopewas handed the same$null, so the signature table came back empty and no swatch was ever computed. Instrumented on the unmodified file:themeNames is null? True,@(themeNames).Count = 1,signatures.Count = 0,pairs actually compared = 0, and a green tick.The re-enumeration is the repo's existing pattern for this, but on its own it would only have fixed half the defect: if the names were right and the
InModuleScopehandoff were broken, every lookup returns$null, every pair "collides", and the test fails with 120 bogus collisions rather than saying no swatch was computed. So the guard is three assertions -- that enough themes reached the run phase, that a signature exists for every one of them, and that the number of pairs compared is the number the loop bounds imply.
Added
tstyles profiles, which lists the Terminal.app profiles named after a style and, with-Clean, deletes the numbered duplicates left by every-NewWindowbefore the fix above. It removes ONLY the numbered ones: the unsuffixedevamay be a profile the user made themselves, and nothing can prove otherwise, whileeva 3is a suffix Terminal.app appends on an import collision and so can only have come from a repeated import. The listing names every profile it will delete before asking -- the rule the uninstall consent listing had to learn in 0.8.23 -- and touches nothing else, including the default profile and any window already open.
Changed
-
the header of
tests/Get-SchemeSwatch.Tests.ps1claimed that comparison catches "all themes look like rainbows". It does not, except in the degenerate byte-identical form: restoring the historical fixed-hue candidate list inGet-SchemeSwatchleaves every theme rendering its OWNbrightRed, so the bytes differ while the rows look alike, and the fixed test stays green. The property that actually protects the picker is that the first cells are the theme's own background, foreground and cursor -- the candidate ORDER -- and nothing asserts it. The claim is corrected rather than the test stretched to cover it; the production code is not defective here, and the assertion is true as named. -
uninstall's
$PROFILEhalf is testable for the first time. Its paths come from RUNNING each engine (& $cmd.Source -Command 'Write-Output $PROFILE'), so the only profile any test could reach was the one belonging to whoever was running the suite -- which is why none of the three$PROFILEdefects above was ever caught, the same shape as the rc omission fixed in 0.8.23. Discovery is nowGet-PowerShellProfileTargetand the strip isRemove-PowerShellProfileLoader -Target, an internal injection of the same shape and purpose asInvoke-TerminalStylesRegister -Targets; nothing in the new tests goes near a real$PROFILE. -
uninstall no longer processes one
$PROFILEtwice.pwshandpwsh-previewreport the same path on a macOS machine that has both, so a malformed or unwritable profile would now have printed its warning once per engine for a single file. Windows' two engines keep separate directories and nothing merges there. -
the test that was supposed to guarantee uninstall used the shared engine probe asserted that the text of
Invoke-TerminalStylesUninstallcontained the stringGet-PowerShellEngineCandidate. That is the same kind of test 0.8.22 already caught being worthless once -- "sharing a helper name is not symmetry, and it passed for the whole life of the bug" -- and it broke on a refactor that moved the call without changing a single behaviour. It now follows the discovery function, asserts that the caller actually reaches it, and checks behaviourally that every discovered target carries a label the probe issued. -
the suite now fails the RUN when a test has written to the operator's own files, rather than waiting for someone to notice their prompt is gone. Every seam that keeps a test out of them --
-HomeDir,-ZDotDir,-Targets,$script:TStylesDataRoot-- is OPT-IN, so a test that forgets one is silently correct: it passes, prints nothing, and edits the machine running it. That is three times now, counting 0.8.22's ZDOTDIR leak into a real.zshrcand the two above, and fixing each of them fixes one file.tests/Zz-RealStateUntouched.Tests.ps1photographs the real data root, the rc filesGet-ShellRcRemovalCandidateitself names and both$PROFILEpaths inBeforeDiscovery-- Pester discovers every container before it runs any, so that is the earliest moment in a run -- and compares them after the last test, naming every path that was created, changed or removed. It asks the module which rc files those are rather than listing them again, because a second list is a second answer, and it proves its own probe can see a write (including a dotfile, whichGet-ChildItemwithout-Forcecannot) rather than assuming it. The name sorts last on purpose and one of its tests asserts that it still does: a guard that quietly stops covering the files added after it is worse than no guard. Windows Terminal'ssettings.jsonand Terminal.app's plist are deliberately outside it -- the terminal rewrites those on its own schedule, and a check that fails for something no test did is one that gets deleted.
Full changelog: v0.8.23...v0.8.24