Skip to content
16 changes: 8 additions & 8 deletions .llm/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ frontmatter validity, index freshness, and pointer-file delegation; see
and its bound; reserve `Clone()` for an acceptable `object` return. Counting loops hoist
`List<T>.Count`, interface `Count`, and UIToolkit `childCount` reads out of the condition
(per-iteration property/interface dispatch, PR #73). **Never hoist a `Length`/`Count` read
when the body mutates that collection** - the inline re-read is what makes the loop
terminate. Traps: [hot-path-allocations](./skills/hot-path-allocations/SKILL.md).
when the body changes that collection's count** - the inline re-read is what makes the loop
terminate. One read per method (a count has one source of truth), and the three exceptions
to that: [hot-path-allocations](./skills/hot-path-allocations/SKILL.md).
19. Comparison operators read left-to-right in ascending order: only `<`, `<=` and `==`. Never
`>` or `>=` -- write `0 <= index` and `b < a`, not `index >= 0` or `a > b` (issue #51).
Enforced by `npm --prefix tooling~ run lint:comparison-direction` (pre-commit + CI; `:fix`
Expand Down Expand Up @@ -228,7 +229,7 @@ frontmatter validity, index freshness, and pointer-file delegation; see
- Completion providers always receive a context scoped to the command's own arguments:
`ActiveArgumentIndex`/`PrecedingArguments` are relative to that command, and the router shifts them per
routing level (`CommandCompletionContext.ForSubcommand`). Never hand a provider a parent-shifted context.
- Details: [register-terminal-command](./skills/register-terminal-command/SKILL.md).
Details: [register-terminal-command](./skills/register-terminal-command/SKILL.md).

### User-Facing Copy (STE)

Expand All @@ -248,11 +249,10 @@ Details: [simple-writing](./skills/simple-writing/SKILL.md).

`CHANGELOG.md` records only changes a package consumer can observe: public API and
serialized-data changes, behavior changes, fixes, install-size or console-output changes.
Internal work (refactors, tooling, style enforcement, linters, measurement, CI lanes)
stays out, and so do internal numbers (timings, allocation counts, test tallies) - those
live in PR descriptions, issues, and `progress/` logs. If a user cannot observe the
difference, it does not belong in the changelog; new `Unreleased` entries keep the
existing `Added/Changed/Fixed/Removed` buckets per the policy in the file header.
Internal work (refactors, tooling, style enforcement, linters, measurement, CI lanes) stays
out, and so do internal numbers - those live in PR descriptions, issues, and `progress/`
logs. If a user cannot observe the difference it does not belong in the changelog; new
`Unreleased` entries keep the existing buckets per the policy in the file header.

### LLM Attribution (GitHub)

Expand Down
60 changes: 42 additions & 18 deletions .llm/skills/hot-path-allocations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,50 @@ first-inserted-wins for case-variant duplicates.

- Hoist `List<T>.Count`, interface `Count`, and UIToolkit `childCount` out of a counting
loop's condition: those are property or interface reads, re-dispatched per iteration.
- **Never hoist a `Length`/`Count` read when the loop body mutates that collection.** The
inline re-read is what makes the loop terminate; a hoisted value pins a stale count. Three
in-repo loops are traps that read like hoistable candidates, and all three are commented
as such: `CommandShell`'s provider and scan-assembly removals (`RemoveAt` in the body),
`CommandAutoComplete.CollapseCaseInsensitiveDuplicates`' outer rewrite loop
(`words[writeIndex] = ...` in the body - its *inner* read loop is safe and is hoisted), and
`TerminalUI`'s `while (content.childCount < logs.Count)`, which appends the labels it is
counting. Hoisting any of them is a bug, not an optimization.
- **One count per copy, and it is the one the allocation used.**
`new CommandArg[arguments.Count]` followed by
`for (int i = 0; i < materialized.Length; ++i)` states the same count twice,
in two expressions that can drift: change the allocation - a subrange, a
clamped size, a filter - and the copy silently under-runs instead of
failing. Hoist it and let it size both, the way
`BorrowedCommandArguments.ToArray` already does. This is a drift rule, not a
speed one, and it is a review rule rather than a lint rule: a token-level
linter cannot see that two expressions name the same count.
- **Never hoist a `Length`/`Count` read when the loop body changes that collection's
COUNT.** Overwriting an element (`words[writeIndex] = ...`) is not a change of count; only
an add, a remove, or a clear is. The inline re-read is what makes such a loop terminate, so
a hoisted value pins a stale count. Three in-repo loops are traps that read like hoistable
candidates: `CyclicBuffer.Resize`'s `RemoveRange`, the `Fix Invalid Fonts` `RemoveAt` loop
in `TerminalFontPackEditor`, and `TerminalUI`'s `while (content.childCount < logCount)`,
which appends the labels it is counting. Hoisting any of them is a bug, not an optimization.
`KebabCase`'s trailing `builder.Length -= 1` is a fourth, in a `while` that shrinks the
buffer it reads. `CollapseCaseInsensitiveDuplicates` used to be listed here on the same
wrong reasoning - its outer loop only overwrites elements - and was hoisted once that was
corrected, so treat an inherited justification as a claim to verify, not as a fact.
- **One read per method, so a count has one source of truth.** A `Length`/`Count` read
three or more times in one method states one number in three places, and they can
drift: change the allocation - a subrange, a clamped size, a filter - and the loop
bound silently under-runs instead of failing. Read it once into a local and let it
size the allocation and the bound, the way `BorrowedCommandArguments.ToArray` and
`LogTextSanitizer.SanitizeInto` do. This is a **drift rule, not a speed one**, and it
is a review rule rather than a lint rule: a token-level linter cannot see that three
expressions name the same count.
- **Two reasons a second read is a different number, and both leave the reads alone.** A
reviewer who hoists either introduces a bug:
1. The loop body changes that collection's count (the rule above).
2. The variable is reassigned between the reads, so they are genuinely different
values - `TextFieldPaste.TryApply`'s second read is the post-trim `flattened`, and
the `_history.Count` in `CommandHistoryTests` follows a `Push` that changed it.
- **A test is a third kind of "do not touch", for a different reason.** The count is what
the assertions compare: either the repetition IS the assertion
(`CountReflectsNumberOfEntries` reads `history.Count` once per push, with a different
expected value each time - hoisting passes vacuously) or the bound is the subject under
test. Either way a hoist changes what is being checked. Do not sweep tests mechanically.
- **Sweep for it, then classify every hit, and never inherit a classification.** Three or
more reads of one `X.Length`/`X.Count` in a method body, across `Runtime/`, `Editor/`,
`Tests/`, `Samples~`, `Generator~`, is a hand-reviewable list. The tally is not the
deliverable and should not be quoted: the first sweep that quoted one classified 9 of 22
sites as hoistable and called the rest traps, and a second, independent sweep of the same
rule found 11 more hoistable sites in the same files - including one 65 lines below a site
the first sweep had already hoisted, and a site the first sweep had excused by believing
an inherited comment. Write the per-site decision, not the count.
- Hoisting `string.Length`/`array.Length` is a **measured wash, not a win**: with tiered JIT
disabled the inline and hoisted forms were identical at the median (0.00%, n=2000 x 7
interleaved reps, 13- and 200-char workloads). The JIT already hoists the load; the
"bounds-check elision" rationale that used to sit in context.md rule 18 was never real.
Write whichever form reads clearly and keep one form per type.
Write whichever form reads clearly - except where the one-read rule above applies, which
wins.

## Probe methodology (allocation tests)

Expand Down Expand Up @@ -182,5 +204,7 @@ first-inserted-wins for case-variant duplicates.
- Shared rented buffer? Lease-guarded slots + evict oversized on return.
- New mutation site on a snapshotted collection? Bump the version.
- New copy or fill loop? One count, read once, sizing the allocation and the bound.
- New method that reads one count three or more times? One read, unless the loop mutates
that collection, the variable is reassigned, or it is a test (see Loop bounds).
- New allocation test? Warm first; pin through `AllocationAssertions`.
- New lambda argument on a memoization/registration path? Captureless means `static`.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- The package's own inspectors now escape the names they print. The custom inspectors for `TerminalUI`, `TerminalThemePack`, and `TerminalFontPack` built their tooltips and dropdown labels from theme names, font names, command names, and asset paths with no escaping, so a name carrying a bidirectional override, a zero-width joiner, an invisible tag character, or a control character read one way there and another way in the value it selected. Those tooltips and popup labels render the same visible escapes the console shows. The value an index selects is still the raw name, so `SetTheme`, `SetFont`, the theme and font dropdowns, and the disabled-command list are unchanged. One limit, the same one the log has: a name holding the literal text `Admin\u202Eexe` prints the same as a name holding a real override.
- A command that throws now says so in the console. A handler exception used to escape the terminal's input path, so in a device or standalone build it produced no in-game output at all and only Unity's own logger saw it. It is now contained and reported like every other command failure - one line in the log and in the quick-launch bar's error bar, naming the command, the exception type, and its message - while `Debug.LogException` keeps the stack trace in the Editor console and the player log. Later commands still run, and `RunCommand` still answers `true`, because the command ran and the failure is on the error queue.
- The quick-launch bar no longer closes over its own error. A command that ran and then reported a controlled error through the shell's error queue - a builder validation failure, a rejected value, a thrown handler - returned success to the bar, which closed and took the error text with it, so the developer saw nothing at all. The bar now keeps the error visible, keeps the rejected line editable, and reports the submission as failed. A command that printed output still keeps the bar open, unchanged.
- History recall now parks the caret at the end of the recalled line. Pressing Up or Down in a line the developer had already typed left the caret where it was, so the next character landed in the middle of the command they had just recalled. A recalled line is a new value, so the caret moves with it; applying a suggestion from the bar does the same. As with every other caret write, this needs Unity 2022.1 or newer: on 2021.3 the engine places the caret after the value lands.
Expand Down
14 changes: 14 additions & 0 deletions Documentation~/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ packs live under `Packs/`; add your own assets to extend either list.

`list-themes` and `list-fonts` print every entry in the active packs.

A font draws only the glyphs it carries, and a character it lacks shows a
missing-glyph box. All 42 shipped families cover basic Latin. Two carry
more, and `list-fonts` prints them by these names:

- `MPLUS1Code-Regular` - CJK and kana. In `Large`, `Everything-Basic`, and
`Everything-All`.
- `NanumGothicCoding-Regular` - Hangul and kana. In `Everything-Basic` and
`Everything-All`.

Six families carry between one and five emoji between them, so an emoji
usually shows a box. To see a script the font does not have, add a font
that carries it to a pack and set that font. The console keeps the text
whole either way.

## Switching at runtime

`TerminalUI` exposes both switches. `persist: true` stores the choice
Expand Down
2 changes: 1 addition & 1 deletion Editor/CustomEditors/TerminalFontPackEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ public override void OnInspectorGUI()

GUIContent loadFromCurrentDirectoryContent = new(
"Load From Current Directory",
$"Loads all fonts from '{assetPath}'"
$"Loads all fonts from '{LogTextSanitizer.Sanitize(assetPath)}'"
);

if (GUILayout.Button(loadFromCurrentDirectoryContent))
Expand Down
2 changes: 1 addition & 1 deletion Editor/CustomEditors/TerminalThemePackEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public override void OnInspectorGUI()

GUIContent loadFromCurrentDirectoryContent = new(
"Load From Current Directory",
$"Loads all themes from '{assetPath}'"
$"Loads all themes from '{LogTextSanitizer.Sanitize(assetPath)}'"
);

if (GUILayout.Button(loadFromCurrentDirectoryContent))
Expand Down
Loading
Loading