Skip to content

Latest commit

 

History

History
467 lines (380 loc) · 150 KB

File metadata and controls

467 lines (380 loc) · 150 KB

Agent guide

Project-specific gotchas not obvious from the source. CLAUDE.md symlinks to this file.

Audience and scope

This file is for contributors (human or AI). README.md is for users. Keep them disjoint:

When a PR adds a user-visible feature (a CLI flag, a new Settings section, a keybinding), update README.md only if it changes one of: what the app is, how to install it, how to run it, or where to get help. Otherwise the change belongs in AGENTS.md.

Per-feature catalogues ("what works today") don't go in the README — they belong in the issue tracker and release notes. Workflow-level docs (branches, PRs, releases) live in CONTRIBUTING.md.

Quick start

dotnet build TuiCode.slnx
dotnet run --project src/TuiCode                       # needs a real terminal
DOTNET_ROOT=$HOME/.dotnet dotnet test TuiCode.slnx     # DOTNET_ROOT only needed for `dotnet test` on macOS

Solution map

  • src/TuiCode/ — entry point + composition root.
  • src/TuiCode.Workbench/ — shell, parts, services, settings UI.
  • src/TuiCode.Editor/ — EditorGroup, EditorTab.
  • src/TuiCode.Explorer/ — FileExplorerView.
  • src/TuiCode.Search/ — TextSearch / WorkspaceSearch (pure) and the sidebar SearchView.
  • src/TuiCode.Syntax/ — TextMate grammar bundle for syntax highlighting (TG-free).
  • src/TuiCode.Icons/ — file and folder icons for the explorer, Find results, Review tab and Open dialog.
  • src/TuiCode.Abstractions/ — interfaces + DTOs, plus PaneTabs, the one view type shared by two features. Features depend only on this.
  • tests/TuiCode.Tests/ — single test assembly.

Tests

  • xUnit v3 on Microsoft.Testing.Platform. Don't switch to v2 + Microsoft.NET.Test.Sdk — pulls in Microsoft.TestPlatform.CoreUtilities 15.x, which TG's ConfigurationManager chokes on at startup.
  • Test names: Method_describes_what_should_happen (snake_case after the method).
  • File-touching tests: MockFileSystem from System.IO.Abstractions.TestingHelpers. No temp dirs.
  • Never assert a MockFileSystem path as a literal. On Windows the mock resolves /work against the current drive, so FullName is C:\work and a hard-coded "/work" fails there and only there — a HostSteps.Run step waiting on one just reports RunAsync timed out. Put the expected path through fs.Path.GetFullPath (see StartupArgumentsTests.Full).
  • UI / focus / key bugs: drive via TG input injection (host.App.InjectKey from the Iteration event). See WorkbenchHostTests.CtrlQ_quits_the_workbench and TG's testing docs. Faster than asking a human to retry manual steps.
  • Any test that boots a TG Application (news up a WorkbenchHost, renders a View) or mutates ThemeManager/ConfigurationManager must derive from StaticConfigurationTest (issue #77). Those TG statics are process-global; under xUnit's default parallelism a theme mutation in one test makes TG's render path throw KeyNotFoundException in another. The base joins the serialised StaticConfiguration collection and snapshot/restores the theme. Most tests don't touch TG statics and stay parallel — only opt the ones that do into the base. A reentrancy guard in the base throws if the serialisation ever breaks, so a forgotten [CollectionDefinition] rename fails loudly instead of flaking.
  • Tests that boot a WorkbenchHost must also pass driverName: DriverRegistry.Names.ANSI (the headless, deterministic CI driver). With the default (null) TG auto-selects the platform driver; on a headless Windows runner that's WindowsDriver, which blocks on console input and hangs the run forever — RunAsync doesn't observe the cancellation token while blocked. Linux auto-selects ANSI already, which is why this only bit the Windows CI leg. Production passes null so a real terminal still gets the best driver.

Terminal.Gui v2

  • Namespaces split: Terminal.Gui.App, .Views, .ViewBase, .Drawing, .Input. global using per assembly.
  • Static Application is [Obsolete]. Use Application.Create(timeProvider).Init(...).Run(runnable) against IApplication. WorkbenchHost owns it.
  • Tabs.Add(view); tab title = child's Title; active = Tabs.Value.
  • Setting a tab's Title doesn't redraw its header. TG copies the title into the header only during layout, and Tabs places headers using each header's cached width (ITitleView.MeasuredTabLength) before re-measuring it. So after changing a title, clear that cached width and call SetNeedsLayout() (see EditorTab.UpdateTitle). Otherwise the header shows the old text until something else forces a layout, and neighbouring headers overlap when the width changes.
  • Removing a focused modal makes TG hand focus back into the editor group, and a Tabs switches Value to whichever tab takes focus, which isn't always the active one. So close a modal before making a tab active, not after (see OpenRevisionPicker, #191).
  • TextView.IsDirty has no setter. EditorTab tracks _dirty via TextView.ContentsChanged (subscribed after the initial Text = so load doesn't dirty it).
  • ContentsChanged isn't a reliable edit signal in TG 2.1.0. The kill commands (Ctrl+K, Ctrl+Delete, and mid-line Ctrl+Backspace / Ctrl+Shift+Backspace) edit without raising it, and some no-op deletes (Backspace at the start of the file, Delete at the end) raise it anyway. So EditorTextView.OnKeyDown runs every key bound to an editing command (EditCommands), and OnKeyDownNotHandled runs typed text, with TG's own raises held, then raises once if the undo recorder found a change (see Multiple cursors and undo).
  • TextView.OnContentsChanged throws away TextModel's max-line-width cache on every edit (still true in TG 2.5.0), so the next UpdateContentSize measures every line: ~225 ms per typed character at 50,000 lines. EditorTextView.OnContentsChanged works out the new width first by folding in the current row (the cached max plus _cachedMaxWidthPerLine, the rows at that width), calls base, then puts the cache back. This relies on every in-place width change being on the current row: adding, removing or replacing a line already invalidates the cache, and so do edits at several cursors. It reaches TG's private members through [UnsafeAccessor], plus reflection for _model, since UnsafeAccessorType can't return a ref to an inaccessible type. If a TG upgrade renames them, every edit throws and EditorTextViewContentWidthTests fails. Enter, line joins, and edits that make the widest line wider (or shrink the only widest line) still rescan once, because TG's own checks invalidate before the hook runs. EditorTypingBenchmarkTests (explicit) measures it.
  • Application.RemoveDefaultKeyBinding(Command.Quit) crashes TextView.PopoverMenu init. To stop Esc-as-Quit, reassign Quit to Ctrl+Q — see WorkbenchHost.NeutralizeBuiltinQuitKey.
  • View.SetFocus() returns false when any ancestor has CanFocus = false. Set CanFocus = true on container Views that should host focusable children.
  • ConfigurationManager deserializes via source-generated JsonTypeInfo — only knows the types its built-in scopes use. Records, arrays, even string[] silently fail to load. Stick to primitives or persist to a dedicated file.
  • Terminal.Gui.Drawing.Attribute collides with System.Attribute; fully qualify when constructing.

UI controls

Styles and conventions for how we build TUI apps live in the MentalDesk TUI style guide. Read it before describing UI in an issue or PR, and before building one.

Each rule is built once in the guide's shared library, MentalDesk.Tui, and shown working in its reference app, Swatch. TuiCode.Workbench references it as the MentalDesk.Tui package (FocusBorder and TerminalFlowControl come from it). Where the library has a type, use it rather than a TuiCode copy; a change it needs goes to the style guide first, as its own PR. Dependabot opens a PR here for each new version, and its Terminal.Gui version moves with ours.

What follows is how TuiCode implements it today.

  • LogView is the one custom view: TextView can't scroll without moving its cursor.
  • AlertView (Workbench/Controls/) is the guide's message block. It wraps the message and reports the rows it needs (Lines); the dialog grows by that many and moves its hints up (SubmitReviewView.Alert). AlertSeverity.Error draws it in the theme's Error scheme, Info in Accent. sr, gs and cc use it — PathPromptView, RevisionPickerView and PullRequestPickerView still have the one-row Label (#208).
  • InputView (Workbench/Controls/) is the guide's bordered multi-line field, used by sr and cc. It draws its box heavy while it has focus and single while it hasn't (#223), and leaves where typing goes to the terminal cursor, as the editor does — it paints no caret of its own, so nothing covers the character the cursor is on. A dialog hosting one is InputView.Frame rows and columns taller for the box.
  • SubmitReviewView.Hint is the guide's clickable hint: a Button with NoDecorations, NoPadding, no shadow and no hotkey of its own.

AOT

  • Release builds are Native AOT (PublishAot=true on src/TuiCode). dotnet publish -c Release -r <rid> emits a single native binary; dotnet build/dotnet run still JIT.
  • All src/ projects set IsAotCompatible=true, so trim/AOT analyzers run on every Debug build. Don't silence warnings — fix the call site.
  • JsonArray.Add(JsonNode) is AOT-safe; the generic JsonArray.Add<T>(T) overload is not. When appending a JsonObject/JsonArray, cast to JsonNode to pick the right overload (see DefaultSettingsService.SaveKeybindings).
  • dotnet test runs JIT, so AOT-only failures (missing metadata, trim-stripped paths) won't surface there. CI's aot job publishes the binary on every release RID and runs --smoke-syntax (headless: loads every grammar and theme, tokenizes a line with each). On linux-x64 it also runs ./TuiCode --smoke under a pty — boots through Application.Init, renders one iteration, exits 0. Add anything reflection-heavy with those smokes in mind; an AOT-compatible test framework is tracked separately.

Key handling

  • IApplication.Keyboard.KeyDown fires before view dispatch. Single subscription: WorkbenchHost.OnAppKeyDown. Set Key.Handled = true to consume.
  • All keybindings go through IKeybindingService; never wire KeyDown on individual views. To add a binding:
    1. Constant in TuiCode.Abstractions.CommandIds.
    2. Register handler in WorkbenchHost.RegisterDefaultCommands via the labelled Register(id, label, handler, scope) overload.
    3. Bind in WorkbenchHost.BindDefaults (called by ApplyKeybindings).
    4. Give it a home in the menu bar: its place in CommandMenu.Layout. MenuBarHostTests fails for a command with a mnemonic and no menu item, unless it's in CommandMenu.Unlisted.
  • Command scopes (#132). Every command has a fixed CommandScope from registration: Global (the default), Editor, Explorer, Find, Diff or Review. A binding lands in its command's scope, user overrides and - removals included, so the keybindings file carries no scope. The store is one trie per scope; Handle tries the focused scope first (WorkbenchHost.FocusedScope: Find = the Find sidebar's inputs, Explorer = the explorer, Editor = the editor content, Diff = a diff tab, Review = the Review tab), then Global. So one chord can mean different things per scope, and a scoped binding beats a Global one on the same key. A chord finishes in the scope it started in. A command registered with isEnabled that says no is skipped, so its key falls through to Global: explorer Esc cancels only a pending cut and editor Esc only clears a selection or extra cursors, otherwise it focuses the editor. The document-changing commands are Editor (#283): move/duplicate line up/down, indent/outdent lines, add cursor above/below, select next/previous/all occurrences, toggle column select, plus remove secondary cursors, clear selection and go to symbol. So are the commands that act on the open file (#286): go to line, change grammar, show document info and previous/next cursor position. Save, close, next/previous tab and focus-tab-N stay Global — they act on the tab as a whole and are useful from the file tree. ScopedKeybindingsHostTests pins the whole Global set, so a new document-changing command registered without a scope fails there rather than becoming a key that edits the file you aren't in. The command palette (Ctrl+E) lists Global plus the scope the keys were in when it opened, less whatever isEnabled says no to (#284): ActionView filters ICommandService.Registered on the CommandScope WorkbenchHost.OpenActions captures — captured there because opening the palette pushes a modal input scope over the region it came from — and on IsEnabled, so a command with no editor open, no git repo or no pull request loaded isn't offered. The exceptions are the commands whose no-editor behaviour is the point: Ctrl+F/Ctrl+H with no tab open the Find pane, and Esc says "Nothing to focus in Editor". Settings › Keyboard Shortcuts stays the unfiltered reference. Each palette row ends with the command's mnemonic from CommandMnemonics (#341), right-aligned so the column lines up, under Command / Binding / Mnemonic headings (ActionRow.Header, laid out like the rows and drawn in the palette's colours inverted, below a Filter: label); ActionRow.Display lays the row out at the list's width and shortens the label first, never the mnemonic. The filter matches label, keys and id by substring and mnemonics by prefix, so s lists the s… family as the leader would. Scope governs the keys, the palette's list and the leader's list (#285): a command you can't run where you are isn't offered anywhere but that reference. Both filter on scope and isEnabled alike.
  • Chord trie keyed on Key; "Ctrl+W X" is a single binding. Esc cancels in-flight chords; stray keys abort and are consumed silently.
  • Showing a key to the user goes through KeyChord.Display, which renders the cursor keys as arrows (Alt+↓, not TG's Alt+CursorDown, #194) and everything else as Key.ToString(). It's display-only and never parsed back, so identity stays KeyChord.Canonical. The status-bar hints, the keybindings picker, the command palette and the in-flight chord all use it.
  • Bare letters drop Shift and lowercase: "x", "X", "shift+x" collide. KeybindingService.Bindings emits the lowercased form. Casefold in UI if needed.
  • Chord wins over view bindings: Ctrl+W shadows TextView.Cut. Use the unshadowed alternative (Ctrl+X).
  • Input scopes are a stack (IInputScopeStack). Workbench scope is bottom, never popped. Modals push their own KeybindingService on open / pop on close — workbench shortcuts don't fire while a modal is up. New modal: instantiate KeybindingService, push, register bindings against it, pop on close.
  • KeyCaptureScope (Services/) is a third kind: absorbs every key, routes to a callback. Used by the keybindings picker (recording a chord) and the mnemonic dialog (reading the typed mnemonic). Always pop on commit/cancel. Note it consumes everything — while it's the top scope even Ctrl+Q won't quit, so a host test that opens such a modal must Esc it shut (or let it auto-execute and close) before injecting Ctrl+Q, else RunAsync times out.
  • Keybindings picker (#142) lists every command with a When column (its scope), which the filter also matches. Conflicts (KeybindingClash) are only checked where bindings can clash: in the same scope it refuses or offers to replace; a scoped binding against a Global one only warns, since the focused scope wins; Explorer vs Find never clash. Save diffs by (scope, chord). Modal-scope bindings deliberately aren't editable (rebinding them could trap the user).
  • The picker won't record a chord whose first key types text (a letter, digit, punctuation or Space without Ctrl/Alt) — such a binding swallows that key everywhere, including the editor. Later chord steps can still be bare (Ctrl+G L). Hand-edited files aren't checked.
  • Ctrl+Enter on Windows arrives as Ctrl+LineFeed. The Win32 console swaps Enter's produced char from CR (0x0D) to LF (0x0A) when Ctrl is held, and the native windows driver passes that control char straight through, so the event reaches us as CtrlMask | 0x0A (0x4000000A) — which never matches a Ctrl+Enter binding (CtrlMask | Enter = 0x4000000D). WorkbenchHost.NormalizeWindowsCtrlEnter rewrites it back to Ctrl+Enter for binding lookup (gated on IEnvironment.IsWindows; off Windows the ansi/kitty path already delivers CR). The rewrite runs before the scope-stack dispatch, so it reaches pushed modal scopes too — the concrete consumer is the settings dialog's Ctrl+Enter: Save (_scopeKeybindings.Bind("Ctrl+Enter", CommandIds.SettingsSave)), which was silently dead on Windows before this. The diagnostics overlay still shows the raw 0x4000000A so the underlying arrival stays visible. This is a sibling of the Ctrl+0/three-modifier gaps — a chord the legacy console mangles — but here the info survives, so we reconstruct rather than abandon it. Other VK↔char collapses (Ctrl+[, Ctrl+I/Tab) would need the same treatment if a binding ever depends on them.
  • Ctrl+/ without the kitty protocol arrives as Ctrl+7 (#388). The terminal sends 0x1F (also what Ctrl+_ and often Ctrl+7 send), and the ansi and dotnet drivers decode it as Ctrl+7; the windows driver reads the key (VK_OEM_2) and already gets Ctrl+/. WorkbenchHost.NormalizeLegacyCtrlSlash looks Ctrl+7 up as Ctrl+/, after the Ctrl+Enter rewrite and before the scope stack, so bindings, the palette and Settings all still say Ctrl+/. It stands aside when kitty flags are negotiated (Driver.KittyKeyboardCapabilities), where Ctrl+7 is real, and when the user's own overrides bind a chord starting with Ctrl+7, so a deliberate binding wins. The default Ctrl+7 (Focus editor tab 7) doesn't count: in those terminals it was already indistinguishable from Ctrl+/. LegacyCtrlSlashDecodingTests pins each driver's decoding.

Mnemonics (leader key, #50)

  • A leader key (Ctrl+Space by default, rebindable like any shortcut → CommandIds.ShowMnemonics) opens MnemonicView: a modal launcher that lists the mnemonics of the commands in scope and enabled where it opened (WorkbenchHost.MnemonicsInScope, captured before the modal scope is pushed) and fires the command the instant the typed prefix is unambiguous — no Enter. Esc cancels, Backspace edits.
  • Mnemonics are hard-coded design decisions, not user-configurable — only the leader key is rebindable. The table lives in CommandMnemonics (Abstractions), keyed by command id, next to CommandIds. Commands with no entry (ShowActions, ShowMnemonics itself) simply don't appear.
  • Invariant: no complete mnemonic is a prefix of another (guarded by CommandMnemonicsTests.No_mnemonic_is_a_prefix_of_another). That's what lets the auto-execute rule work without a terminating key. Mnemonics come in families under a shared first key with room to grow (cf close-file reserves co/ca/cs; ts/tg/tc toggle; nf/nc/nt and pf/pc/pt new/next and previous; fs/fe/ft/fr/f1…f9 focus; gl/gs/gp/gn go to, and gb git blame; cg change grammar and cc create comment, the two cs that aren't close; ff/fg and rf/rg find/replace in the active file vs. globally — a trailing f means the active file, as in sf/cf). A mnemonic is the command label's initials — fg Find globally, nt Next tab, rc Revert change. Where the initials read badly or are taken, change the label rather than inventing letters ("Find globally", not "Search in files"); the standing exceptions are the trailing-f family, q/?, tui and f1…f9. mu/md move and du/dd duplicate lines, il/ol indent and outdent them, and tlc toggles their line comment; aa/ab add a cursor above/below. q/? are the only single-key ones. tui (About, #107), opr (#184), or (#357), pro (#185), gs (#137) and cc (#188, #189) have no default keybinding. ts (toggle sidebar) flips visibility unconditionally, then settles focus against the new state — a freshly shown sidebar takes focus, a freshly hidden one that held focus hands it back to the editor. fs (focus sidebar) just shows-and-focuses. The split (#85) keeps ts honest from the palette/leader, where focus has already left the sidebar before the command runs — the old handler gated the hide on the sidebar being focused, so it never hid from those entry points.
  • Narrowing the list can't make a mnemonic fire early, so there's no second, unfiltered copy of the table to keep in step (#285). ResolveExact fires only on a prefix that is itself a complete mnemonic, and the table is prefix-free, so a complete mnemonic has nothing under it that hiding could reveal. The guard is MnemonicScopeHostTests.Narrowing_the_list_never_makes_a_mnemonic_fire_a_keystroke_earlier, over the real commands; break the prefix-free invariant and it says so. A keystroke no in-scope mnemonic could complete is ignored by the same rejection OnKey always did, over the smaller set — so there's no abort path and no message, which is what makes an out-of-scope command seem not to exist.
  • The matching logic is the pure, TG-free MnemonicResolver (Matching for the hint list, ResolveExact for the fire-now decision) so it's unit-tested directly. MnemonicView is a thin capture-scope shell over it — it reads keys via KeyCaptureScope rather than a focused TextField precisely so a command that disposes the view executes on the app key-dispatch path, not inside a TextChanged callback.

Menu bar (#340)

  • CommandMenu (Workbench/Menus/) fills TG's MenuBar across the top of the workbench from CommandMenu.Layout: eight menus, every command with a mnemonic in exactly one, plus Show all commands and Show mnemonics. The exceptions are the focus moves in CommandMenu.Unlisted, since opening the menu moves focus itself. View lists the sidebar panels by name (CommandMenu.Titles), as VS Code does. An item runs its command by id, through RunLaunched like the palette. Its key column is KeyView.Text, set from the live bindings by ApplyKeybindings, not MenuItem.Key: that holds one Key, not a chord, and the workbench already dispatches the key, so BindKeyToApplication is off.
  • F10 is ShowMenu, rebindable like any other; TG's own F10 hot key is removed so an unbound key can't open it. Alt+<letter> opens a menu through TG's hot keys; the bare letters are removed, since TG would otherwise open a menu on a typed f wherever the keys are. Esc is re-added to each popover's Quit, which NeutralizeBuiltinQuitKey took away.
  • While a menu is open the host pushes an empty key scope, so arrows, Enter and Esc reach the menu rather than workbench bindings. The region is captured as it opens, and each item is dimmed unless its key would run there, in its scope and enabled: the palette's rule (#342). A dimmed CommandMenuItem stays Enabled, since TG's arrows skip a disabled view, and instead draws in the scheme's Disabled colour and swallows its own activation. A menu with no item that could run leaves the bar (CommandMenu.ShowAvailable), re-checked on each focus move and a few times a second, since some isEnabled checks touch the disk. It's taken off rather than hidden because TG's bar keeps a hidden item's place. A picked command is checked again before it runs. The menu won't open over a dialog (Workbench.HasDialog).
  • CommandMenu.Closed fires on the loop iteration after the last popover shuts: moving between menus shuts one before opening the next, and TG is still moving focus as it shuts. App.Invoke runs inline on the UI thread, so it doesn't defer; AddTimeout(TimeSpan.Zero, ...) does. Moving focus any earlier knocked the active tab over to another (see Terminal.Gui v2).

Getting Started dialog (F1)

  • HelpView is onboarding, not a shortcut reference — the command palette (Ctrl+E) lists the commands that apply where you are with their keys, and Settings › Keyboard Shortcuts every one of them. It shows only how to find everything else (palette, mnemonics) plus the handful of keys needed in the first few minutes (open, new, save, close, find, quit). Don't add a row per feature; a new row has to displace one.
  • While no file is open, the status bar's position slot shows Press F1 for help, read from the live ShowHelp binding (WorkbenchHost.ApplyKeybindings), so a rebind updates it and an unbound help key hides it.

Find & search (#33)

  • Matching lives in the pure, TG-free TextSearch (TuiCode.Search), shared by in-file find and workspace search: plain text, case-insensitive, non-overlapping, single-line queries. TextMatch columns are UTF-16 chars of the line; the TextView model counts grapheme cells, so EditorTab converts at its boundary (Select/Replace/SetHighlights/SelectionOrigin). Always compute matches against EditorTab.Lines, never Content (which re-joins with Environment.NewLine).
  • In-file find (Ctrl+F / Ctrl+H): FindController (Workbench/Find/) owns one FindBarView and docks it via EditorTab.SetHeader, following ActiveTabChanged to whichever tab is active. EditorTab.Dispose detaches the header first so closing a tab doesn't dispose the shared bar. All-match highlighting is EditorTextView.OnDrawNormalColor (Highlight role); the current match is a real TextView selection. Typing searches from an anchor (the selection origin/cursor when opened, then the last match navigated to), so refining the query doesn't ratchet forward. Replace goes through DeleteCharLeft + InsertText as one undo step; replace-all runs back to front. While there are matches the controller raises HintChanged with the keys that apply to the focused field (Enter means next in the find field but replace in the replace field); the host shows it via StatusBarPart.SetHint, which overlays the normal message and reverts to it when cleared on close.
  • Find always lands in a search box (#229). WorkbenchHost.OpenFind decides where: the bar on the active file or diff tab, the sidebar's Find pane when no tab is open at all, and — where there's nothing to search or replace, a document, or Ctrl+H on a diff — nothing but a status line saying so (Nothing to replace in a diff — Ctrl+H needs a file tab), with the keys left where they were. FindController.Open only shows the bar; the host then moves the keys into it through FocusService, so a stale HasFocus on the bar can't swallow the move (the region is FindBar, which reads Find). Which of the bar's two inputs has them is shown the way a focused pane is: FindBarView draws that field's label in the theme's Focus attribute through a GettingAttributeForRole hook, since the terminal's cursor — the only other sign — is a colour the theme doesn't own. It asks the app which view holds the keys rather than reading HasFocus, which TG leaves set on the field they've moved on from.
  • Find in a diff (#413). FindController works on a FindTarget: a file tab's buffer, or both sides of a DiffTab, docked with DiffTab.SetHeader. A diff's TextMatch.Row is two per row of AlignedDiff, left side first, so the controller's row-then-column order is the diff's reading order and TextSearch's next/previous work unchanged; DiffMatch is the decoded form. Only the code on each side is searched, never thread or draft rows. The anchor is the diff's current row, and a match moves the current row there and scrolls sideways to show it (DiffTab.ShowMatch). Matches are drawn in the Highlight role over tints, changed words and syntax colours, the current one in Active, as the editor draws a selection. A diff gets no replace row, and switching to one hides it. Matches refresh on DiffTab.Refreshed, i.e. when the diff does.
  • Layered (non-modal) scope. The find bar needs Enter/Tab/Esc of its own without the workbench-shortcut blackout a pushed modal scope causes. LayeredScope gives its own bindings first look only while its predicate accepts the key (e.g. "bar has focus"), falls through to the scope beneath otherwise, and never interrupts a chord in flight below. It's pushed on open / popped on close above the workbench scope. Esc in it also accepts from the editor body, so Esc closes find there before editor-scoped Esc or FocusEditorBody. These keys are field navigation, like a dialog's, so they aren't user-rebindable. The Find sidebar's keys (Enter, CursorDown, Tab, Ctrl+Enter) are ordinary Find-scoped commands.
  • EditorGroup raises ActiveTabChanged(null) itself when the last tab goes: TG's Tabs nulls its value silently when the selected tab is removed and none remain.
  • Global find/replace (Ctrl+Shift+F / Ctrl+Shift+H): SearchView is the sidebar's Find tab — user-facing text says "find", matching the fg/rg commands; "search" survives only as implementation vocabulary (SearchView, WorkspaceSearch). Once hosted in a running app it searches on a background task (cancelling the previous one) and marshals results back with App.Invoke; unhosted (unit tests) it searches synchronously. Open buffers are snapshotted on the UI thread first (IOpenBuffers), so unsaved edits are searched, and replace-all edits open buffers in place (left dirty) while rewriting other files via IFileSystem with EOL/BOM preserved. Replace-all needs a second Ctrl+Enter to confirm and refuses to act on results from a stale query. Skips .git/.hg/.svn/node_modules/bin/obj, files > 2 MB and binaries (NUL in the first 8 KB — which also skips UTF-16). .gitignore isn't honoured yet.
  • Sidebar tabs. SidebarPart is now Tabs (Explorer / Find / Review). Per #33 and #259, the four commands that name a pane (Ctrl+Shift+E, Ctrl+Shift+F, Ctrl+Shift+H, Ctrl+Shift+R) show their tab and focus it, however many times you press them — only ts hides the sidebar, and it stays unbound. They decide on visibility, not focus (#85), so the key, the palette and the leader do the same thing. fs focuses whichever tab is active. Ctrl+Shift+F still takes the replace row off the Find pane, as Ctrl+F does on the bar.
  • Keys needing a capable terminal: Shift+Enter (previous match) and the Ctrl+Shift+letter chords (collapse onto Ctrl+letter in Terminal.app — the fg/rg/se mnemonics always work).

Explorer file operations (#101)

  • Delete (Delete / df) is permanent for now — moving to the Trash is #128 — behind a ConfirmView whose Cancel button has focus, so a stray Enter never deletes. It closes every tab at or under the path, unsaved changes included (the prompt counts them).
  • Rename and move are one command (F2 / mf): PathPromptView (shared with New File or Folder) pre-filled with the workspace-relative path and the name's stem selected; F2 cycles stem → name → extension. TextField selects all on focus, so FocusInput re-applies the selection. Tabs follow via EditorGroup.Relocate (re-keys _byPath, re-infers grammar unless pinned); CursorLocationHistory.Rebase/Forget keep history in step. Path arithmetic is FilePaths (Abstractions, ordinal) and EntryPaths (Explorer).
  • A case-only rename skips our "already exists" check — on a case-insensitive file system the target is the source — and relies on .NET's File.Move/Directory.Move, which handle it on macOS and Windows.
  • Delete and F2 are Explorer-scoped (Delete must still delete text in the editor). Ctrl+D is bound there too: our iTerm2 profile sends forward-delete as ^D, which the editor's TextView treats as delete-right. The target is the explorer selection when the explorer has focus, else the active tab's file. Launchers close — focusing the editor — before running their command, so ActionView/MnemonicView capture whether the explorer had focus when they opened (RunLaunched).
  • Cut and paste (#127) is a second way to move: Ctrl+X marks FileExplorerView.PendingCut (drawn faint in DrawLine, before the icon handler so the icon dims too) and Ctrl+V moves it through the same Workbench.Move as F2, targeting the folder NewEntryTarget would pick. Both are Explorer-scoped, so the editor keeps text cut/paste; CancelCut (Esc) is enabled only while a cut is pending, so Esc still focuses the editor otherwise. Delete/Move clear a cut at or under what they touch. Windows Terminal binds Ctrl+V to its own paste, so the xf/pf mnemonics are the fallback.
  • FileSystemTreeBuilder hands out fresh IFileSystemInfos on every read and TG's Branch.Refresh matches children by reference, so a plain RefreshObject collapses every folder under the refreshed node. FileExplorerView.RefreshKeepingExpansion records expanded paths and re-expands them.
  • Refresh explorer (re, Global, no default key, #332) is FileExplorerView.Refresh: RefreshKeepingExpansion from the root, so only expanded folders are re-read, then the selection is put back by path. A selection that's gone moves to the neighbour of the first vanished entry on its path, found in the tree's pre-refresh listing since the disk no longer has it.
  • Expanded folders refresh themselves (#334). FolderWatcher (Workbench/Files/) keeps one non-recursive watcher per entry in FileExplorerView.ExpandedFolders, capped at 64 (most recently expanded win), debounced 250 ms and flushed through WorkbenchHost.ScheduleFlush into FileExplorerView.Refresh(folders). TG's TreeView raises nothing on expand/collapse, so the view re-derives ExpandedFolders after its own refreshes and on every draw. An automatic refresh only scrolls if the selection was already in view. In host tests the Explorer and DiskWatcher can both watch a folder, so raise events through FakeWatcherFactory.At.

Editor gutter (#23)

  • EditorGutter is a sibling view left of the EditorTextView inside EditorTab, not an adornment. It draws rows from _text.Viewport.Y, which is a file line while the tab is unwrapped and a screen row while it wraps; then it reads each row's line from the wrap map (see Word wrap), numbering only a line's first row. It redraws on the text view's ViewportChanged / UnwrappedCursorPositionChanged, and while wrapped after every draw of the text view, which TG draws first. Anything that moves the text view (e.g. SetHeader) must move the gutter's Y too.
  • Change markers diff the buffer against a baseline via the pure, TG-free LineDiff (Myers, after trimming common prefix/suffix). For a file git tracks it's the file at HEAD (#370), so a save doesn't clear what isn't committed; otherwise it's the lines at load, reset on Save and reload. CommittedBaselines (Workbench/Git/) reads HEAD in the background on open, save and reload (EditorTab.BaselineReset) with git cat-file --filters, so CRLF and .gitattributes checkouts compare clean, and hands it to EditorTab.CommittedLines; a read overtaken by a newer one, or by the tab closing, is dropped. Outside a repo (GitRepository.Contains, no git call), untracked, new or renamed since HEAD, or git failing, it stays null and the save baseline stands, quietly. HEAD moving outside the editor — a commit, checkout, reset or pull in another pane — re-reads every open tab from that repo (#371): HeadWatcher watches each repo's git dir for HEAD, and its common dir for packed-refs and, recursively, refs/heads, which GitRepository.Dirs finds from the filesystem (a linked worktree's .git file names its own git dir, whose commondir names the shared one). Only HEAD, packed-refs and the branch HEAD names count, so git status rewriting the index reads nothing. Events are debounced 250 ms like DiskWatcher's, and a repo whose watch couldn't be set up re-reads a tab when it's shown instead. Undoing back to the baseline clears the markers, which IsDirty doesn't. The diff is lazy: EditorTab.OnEdited invalidates it and the next draw recomputes, so a hidden gutter costs nothing. Past LineDiff.MaxEdits Myers gives up, and the gutter splits the region at lines that occur once on each side (as patience diff does) and diffs between them, so a branch's worth of changes still reads line by line; only a region with no such line is marked modified whole. LineDiff.Hunks, which the undo recorder and AlignedDiff use, still gives up outright.
  • AlignedDiff (the model for the side-by-side diff tab, #61) lays LineDiff.Hunks out as rows: inside a hunk, lines pair as modified until the shorter side runs out, then the rest are left- or right-only. It takes its own give-up limit, since a revision can be far more than MaxEdits away from the buffer. Against a missing side (an added or deleted file) it leaves out the empty line after the other side's final newline, so its counts match git's and the Review tab's (#425).
  • The diff's input comes from LineSnapshot (EditorTextView.Snapshot, shared with syntax colouring), not EditorTextView.LineStrings (which builds a string for every line). TG edits a line's List<Cell> in place, so the snapshot spots changed lines by reference plus List<T>'s private _version (read via [UnsafeAccessor], AOT-safe), trims the unchanged prefix/suffix, and re-reads only the middle. Unchanged lines keep their string instances, so LineDiff's comparisons against the baseline hit string.Equals' reference fast path. Don't use ContentsChanged's row as a change range instead: TG reports the start of some multi-line edits and the end of others, and raises nothing for the kill commands.
  • Edits must reach EditorTab.OnEdited, which is why the Content setter calls it directly. TextView.Text = raises ContentsChanged as well in TG 2.1.0, so setting the whole buffer arrives there anyway — which is why a reload (#269) holds EditorTab._loading over it: taking up the file on disk isn't an edit and mustn't dirty the tab.
  • Only set the gutter's Width when it actually changes — TG's Width setter schedules a full screen clear even for an equal value.
  • Visibility is EditorGroup.GutterVisible (on by default, applied to open and future tabs), toggled by tg. It isn't persisted: that waits for an editor section in Settings.
  • Gutter colours come from the token theme's VS Code colors (editorLineNumber.foreground / activeForeground, editorGutter.addedBackground / modifiedBackground / deletedBackground); TG schemes have no semantic roles for them. Without them (no highlighter, e.g. in tests) markers fall back to fixed green/blue/red and line numbers to the Editable attribute, faint except on the cursor row.

Diff tab (#61)

  • Compare to saved (cts, no default key) opens a read-only DiffTab in the editor group: the file on disk on the left, the live buffer on the right, laid out by AlignedDiff with DiffTab.MaxEdits (5,000) as the give-up limit. DiffTab.ReadLines splits the file with TG's own Cell.StringToLinesOfCells, so an unedited buffer compares equal.
  • Diff tabs sit in the same Tabs strip as editor tabs, so cycling, focus-by-index and closing work from TabCollection, not _byPath. EditorGroup.ActiveTab is null while one is active, so editor commands skip it; use FocusActive to focus whichever kind is showing. Closing a file's tab closes its diff tabs.
  • The diff is recomputed on ValueChanged, i.e. each time the tab becomes active, not on edits. The tab draws both panes itself. Up/Down, PgUp/PgDn and Home/End move its current row (line numbers marked in the Focus role) and scroll to keep it in view; the wheel only scrolls. Left/Right and Shift+wheel scroll both sides sideways one column, Shift+Left/Right a page, the narrower side's text width less a 2-column overlap (#368), stopping once the widest line's end shows on the narrower side; the gutters stay put and tab stops are measured from the line start. For the wheel, TG's ANSI parser reports Shift/Ctrl+wheel as WheeledLeft/WheeledRight but drops a real horizontal wheel. Tints come from the token theme's diffEditor.removedLineBackground / insertedLineBackground, which every bundled theme sets opaque (terminals can't blend VS Code's translucent ones).
  • Changed words (#369): on a Modified row, WordDiff.Changes marks the words that differ with diffEditor.removedTextBackground / insertedTextBackground over the line tint, keeping syntax foregrounds. A word is a run of letters, digits and _, a whitespace run, or one other character; the words are diffed with LineDiff.Hunks. Leading whitespace is left out, so a re-indent marks nothing, and a row whose sides share under 40% of their characters (WordDiff.MinSimilarity) gets no marks. DiffTab.WordChanges is computed in Refresh, not per draw. Every bundled theme sets both keys; a theme without them gets DiffTab.DefaultRemovedText / DefaultInsertedText.
  • One vertical ScrollBar for both panes (#295), in the tab's Padding so Viewport.Width shrinks by a column while it shows and SideWidths splits what's left. It's Auto, sized to the display rows (_rows, thread and draft rows included) over PageHeight, and SyncScrollBar keeps it in step from ScrollTo and BuildRows. Its slider round-trip is lossy, so a sync never feeds back into ScrollTo; only a user's drag, click or wheel on the bar does. A second, horizontal bar (#296) sits along the bottom the same way, spanning both panes and following _column. TG sizes a slider as though each bar cell were one content column, and caps it at VisibleContentSize, but this bar is wider than the text it scrolls, so its range is in bar cells (SidewaysBarScale).
  • Compare to other file (cto, no default key) reuses OpenView, titled for the file, starting in its folder and without the Open this folder button. The picked file is the left side, re-read on each activation like cts. DiffTab.LeftKey (its full path) tells apart diffs against same-named files; LeftLabel is only the display name. Binary and oversized files get no special check, since the editor opens them as-is; read errors go to the status bar.
  • Syntax colours (#158): each side has its own LineTokenCache for the source tab's grammar (pinned or inferred, re-checked on Refresh), so the left side is lexed from its own lines. A frame lexes each side top-down through its last line in view within DiffTab.SyntaxBudget, as the editor does. Tokens set the foreground and style over the row's tint (TokenPalette, shared with EditorTextView). WorkbenchHost.ApplyTokenTheme redraws diff tabs itself, since nothing else marks them dirty on a theme switch.
  • Revert change (Ctrl+R, rc, #245) replaces the current change block's buffer lines with the left side's, driven off AlignedDiff.Block and EditorTab.ReplaceLines — one Edit() recording, so Ctrl+Z in the editor takes the whole revert back in one press. Nothing is written to disk: the tab just goes dirty and Ctrl+S is still what commits. The diff then refreshes in place and the rows the revert wrote are tinted in the Focus role until the next change move, the next revert or the next refresh — no timer. A freshly opened diff sits above the first change (CurrentChange is 0 there, which is what N changes in the status bar means), so revert takes that first change rather than refusing — otherwise the very first Ctrl+R after cts looks dead (#250). It runs in every diff with a live buffer on the right, so cts, ctr, cto and the Review tab's branch and PR diffs all revert the same way and the message names that diff's own LeftLabel (#246). The one diff with no buffer to revert into is a deleted file's (#182), where the key restores the file instead (#247). A revert that only removes lines leaves nothing on screen to tint, so the status message carries it.
  • Revert all changes in file (ra, no default key, #248) puts the whole left side back in one EditorTab.ReplaceLines, so one Ctrl+Z takes every block back together. It's the only part of revert that confirms first — unlike rc it throws away work that isn't on screen — through the same ConfirmView as Delete file, Cancel focused. Nothing is written to disk either. It runs wherever rc reverts; a deleted file's diff has no buffer to write into, so there it points at rc instead.
  • Find (Ctrl+F, #413) opens the find bar above both sides; see § Find & search. The header row and every row below move down by the bar's height (DiffTab.HeaderHeight), and the vertical scroll bar starts below it.
  • Next/previous change (Alt+Down/Alt+Up, nc/pc) and Go to line in file (Enter) are Diff-scoped commands, so while a diff tab has focus Alt+Up/Alt+Down win over the global move-lines bindings (not Shift+F7: iTerm2 swallows it). Next/previous stop at the last/first change and put the change two rows below the top. Enter focuses the source tab on AlignedDiff.BufferLine of the current row, recorded as an explicit jump, and then reveals the whole change block there (#287): DiffTab.CurrentChangeLines is the block's buffer lines — just the target line on a row between blocks, or in a block the buffer has no lines in — which § Navigation then reveals as it does any other jump. While a diff tab has focus the status bar adds the diff's line counts (+96 −21, from AlignedDiff.AddedLines/RemovedLines, so a modified row counts on both sides and a side with none is left out, #395), Change 2 of 5 (N changes above the first) and the keys (the page pair as Shift+←/→), read from the live bindings in ApplyKeybindings. TG leaves focus in the diff tab's header and doesn't clear HasFocus when focus moves on, so DiffTab.IsFocused asks Navigation.GetFocused() instead. That header is also why sideways scrolling (Left/Right, Shift+Left/Shift+Right) is a set of Diff-scoped commands rather than the view's own key bindings (#193): Left/Right from the header used to be claimed for tab cycling before they reached the diff tab, while Up/Down, Home/End, PgUp/PgDn and Enter bubble past it unclaimed. Scoped commands run before TG sees the key, so they are unaffected by #254. DiffTab keeps its bindings for the wheel and for view-level tests.

Multiple cursors and undo (#106)

  • Alt+Up/Alt+Down move, and Alt+Shift+Up/Alt+Shift+Down duplicate, the lines under every cursor or selection (EditorTextView.MoveLines/DuplicateLines). As in VS Code, a selection that ends at column 0 leaves that last line out, and adjacent cursors' lines move as one block. Ctrl+Alt+Up/Ctrl+Alt+Down add a cursor above/below every cursor, keeping the column across shorter lines. Alt+Click adds or removes a cursor, and a plain click or Esc goes back to one. Esc is ClearSelection (#362), Editor-scoped and enabled only with extra cursors or a selection: one press drops both, leaving the primary cursor where it is. RemoveSecondaryCursors keeps its command, with no key. The find bar's layer sits above it, so Esc closes find first. These are workbench commands like any other, so they act on the active tab whatever has focus, then focus the editor.
  • Carets. The primary caret is TG's own insertion point and selection. The others are Carets in EditorTextView._secondary (EditorTextView.Carets.cs). A key at several carets (AtEachCaret) loads each caret into TG's private fields in turn, last in the buffer first, and runs TG's own command there, so TG's movement and editing semantics all carry over. A caret already visited is held as its distance from the end of the buffer, which edits before it don't change. Carets that meet afterwards merge. Only the commands in CaretCommands run at every caret. Copy, cut and paste are our own (paste spreads the clipboard one line per caret when the counts match). Copy and cut at one caret go through the same path, VerifiedClipboard.Write, which reads the clipboard back to check the text arrived; IClipboard.IsSupported is false on macOS while the clipboard works, so it isn't consulted. A cut the clipboard refused deletes nothing, and the workbench reports either outcome in the status bar (#318). When the read-back doesn't match, ClipboardTools copies again through the platform's own program (pbcopy, wl-copy/xclip, clip.exe) and reads that back too (#319). pbcopy and pbpaste run with LC_ALL=en_US.UTF-8: in any other locale they garble non-ASCII text on the way in and undo it on the way out, so the read-back can't catch it (#347); tests swap EditorTextView.ClipboardFallback for one over a fake IProcessRunner, so none spawns a real process. TG 2.1.0's TextView.OnKeyDown reads SelectedLength, which rebuilds the selection by repeated string concatenation, so every key after select-all on a large file took minutes; EditorTextView.OnKeyDown calls it only while autocomplete has suggestions, which is never, since TuiCode doesn't use autocomplete. Anything else, such as select-all, first drops back to the primary caret. A terminal draws only one cursor, and anything we paint fills a whole cell, so in terminals that support kitty's multiple cursors protocol (kitty 0.43+), TerminalCursors has the terminal draw the focused editor's secondary carets in the main cursor's shape. WorkbenchHost queries support at startup (QueueAnsiRequest; a terminal that doesn't answer is dropped after a second). After each LayoutAndDrawComplete (i.e. after the frame is flushed) it sends their screen cells, only when they've changed. It clears them when focus leaves the editor: they aren't tied to the main cursor's visibility, so they'd otherwise show through dialogs. Elsewhere, and while that query is outstanding, EditorTextView hides the terminal cursor and paints every caret, the primary included, as a block: the character it sits before, in the editable colours reversed (#223). A painted bar (▏) would fill the cell and hide that character (#401). Only carets a terminal can't draw are painted, and a single caret anywhere, the editor's or a dialog's, is left to the terminal cursor. The cursor style comes back once there's a single caret. Cursor moves while visiting carets aren't reported to EditorTab.CursorMoved, so navigation history sees only the primary caret.
  • Undo is ours, not TG's (EditorTextView.History.cs). TG 2.1.0's history can't hold an edit at several carets as one step. It also mis-undoes its own kill commands and edits over a selection, and interleaving undo with new edits corrupts it. Every edit is diffed (LineDiff.Hunks) against _recorded, a copy of the lines as of the last recorded edit, and pushed as one EditGroup with the carets before and after. Undo and redo splice those lines back and restore every caret and selection. TG's own history is cleared after each record, and Undo/Redo (the commands and the methods, via new) are ours. Edits reach the recorder through Edit (several carets, line moves), EditAtPrimary (a key, or EditorTab.Replace), or OnContentsChanged as a catch-all for anything else TG edits. The diff scans the whole buffer, costing ~0.2 ms per typed character at 50,000 lines (EditorTypingBenchmarkTests).
  • Selecting occurrences (#113): sno/spo/sao (EditorTextView.Occurrences.cs) have no default keys. From a caret with no selection, they first select the word (letters, digits, _) or whitespace run at every caret; after that, occurrences of that text match only whole words or whole whitespace runs. Starting from a selection matches it anywhere, even across lines. Matching is case-sensitive, and the pure Occurrences class does it over graphemes, so columns are cells. Next/previous skip text that's already selected, wrap around the buffer, and make the new selection the primary caret so it scrolls into view. Select all replaces every caret, keeping the primary's selection as the primary.
  • TG 2.1.0's kill commands ignore a selection and then throw reading it, even with one cursor. InvokeEditCommands deletes the selection instead, as VS Code does.
  • Column select (#114): Ctrl+T C (mnemonic tc) toggles EditorGroup.ColumnSelect, applied to open and future tabs like GutterVisible, and flagged in the status bar (StatusBarPart.SetMode) because nothing else shows the mode is on. While it's on, the extend commands (ColumnCommands in EditorTextView.Columns.cs) sweep a rectangle instead of one stream selection: the box's two corners run the movement on a lone caret so TG decides where it lands, then every row between them gets a caret selecting the same columns, clamped to its own length. The active corner's caret stays primary, so the view follows it. Columns are grapheme indexes, not display cells, matching TG's own up/down movement. Vertical moves keep the box's column themselves — TG's _columnTrack restores it across one short row but not the next. The box is remembered unclamped and dropped by any other key or by anything that goes back to one caret, so the next extend starts afresh. A mouse drag still selects a run of text; only the keyboard sweeps a box.

Editor drawing

  • EditorTextView overrides OnDrawingContent with a copy of TG 2.1.0's TextView draw loop that stops at the viewport bottom. Upstream walks every row from Viewport.Y to EOF, so drawing the top of a 5,000-line file took ~900 ms per frame vs ~20 ms now, and cost grew with file length.
  • Per-cell colouring (find highlights, and syntax highlighting per #21) belongs in OnDrawNormalColor, which is still called once per visible cell. It doesn't call base: base resolves the scheme attribute (allocating) and raises DrawNormalColor per cell, so nothing should subscribe to that event. Resolve attributes once per frame in OnDrawingContent, not per cell.
  • EditorTextViewDrawTests asserts the copy paints exactly what TextView does (tabs, wide glyphs, horizontal/vertical scroll, selection, overwrite cursor, read-only). On a TG upgrade, re-diff TextView.Drawing.cs against the copy; drop it once upstream bounds the loop. EditorDrawBenchmarkTests is Explicit (timing-based): dotnet test tests/TuiCode.Tests/TuiCode.Tests.csproj -c Release -- --explicit only.
  • Editor tabs use TG's built-in ScrollBars (#294, #296), turned on with ViewportSettingsFlags.HasScrollBars on the instance EditorTab builds, not TextView.ScrollBars. Both are Auto and sit in the text view's Padding: the vertical one takes a column while the file is taller than the tab, the horizontal one a row while a line runs past the edge. The gutter's height follows the text view's bottom padding, so it ends level with the last text row. A bar that shows or hides during a layout pass (a terminal resize) changes that padding, but TG clears the relayout it asks for when the pass ends, so EditorTab.OnSubViewsLaidOut lays the tab out again whenever a bar's visibility has changed (#315). A resized bar also re-clamps its slider and TG reads that lossy position back into the viewport, which scrolled a background tab hundreds of lines on a terminal resize, so ScrollBarResize.Hold refuses any value a bar proposes while its own frame is changing, unless it's where the text view already is (#324). ThinScrollBar redraws the horizontal bar in editor and diff tabs as a row of 🀰 tiles on no stipple, since a full-height cell looks twice as thick as the vertical bar, and keeps a blank cell between the slider and an arrow, unless that would leave a one-cell slider nothing to draw (#325).

Word wrap (#378)

  • Ctrl+T W (tww, Editor scope) flips EditorTab.WordWrap for that tab only. A tab opens wrapped when Settings › Editor's Wrap long lines (EditorSettings.WordWrap, #380) is on: EditorGroup.Track copies it once, so saving the setting leaves open tabs alone. Wrap by language (#381) overrides it per grammar id: the user's EditorSettings.WrapByLanguage first, then DefaultWrapByLanguage (Markdown and Plain Text on; a tab with no grammar is SyntaxHighlighter.PlainText), else the global setting (EditorSettings.WrapsLanguage). A tab's grammar at open decides it, so cg doesn't rewrap. The pane's rows are the TG-free WrapLanguageRows; they persist under WrapByLanguage in TuiCode.settings.json. The status bar shows Wrap after the grammar while the active tab wraps. TG's TextView.WordWrap stays off: it swaps the model for a pre-wrapped one, so every row-keyed feature would read wrapped rows. Instead EditorTextView draws each file line across as many rows as it needs, and the text, carets, selection, undo and every stored position stay in file coordinates.
  • WrapLayout (pure) breaks a line at the last space that fits or mid-word, counting cells, with tabs measured from the start of the row; spaces at a break may run past the edge. WrapMap holds every line's row starts and the screen row each line starts on, re-measuring only lines whose LineSnapshot string changed. It wraps two columns short of the text view: one so the vertical bar showing doesn't rewrap, one for the caret at the end of a full row.
  • While wrapped, Viewport.Y is a screen row and the content size is (viewport width, screen rows), set through OnContentSizeChanging, so the horizontal bar hides and the vertical one counts rows. TG still writes the viewport by file line (AdjustViewport, ScrollTo, page and mouse moves), so the Viewport override ignores those writes and the next draw scrolls the caret's row into view instead; only the vertical bar's writes (flagged from its ValueChanging) and our own base.Viewport ones land. Resizing keeps the top line in place.
  • TG's PositionCursor and mouse handling also count file lines, so a wrapped view places the terminal cursor itself after drawing, and handles clicks, drags, double and triple clicks and the wheel itself (EditorTextView.Wrap.cs). OnKeyDown, Load and MoveCursor ask for a draw so a move TG didn't redraw for still gets its cursor placed.
  • ↑/↓ and PageUp/PageDown (and their Shift forms) move by screen row (#379): EditorTextView.MoveByRows handles them before TG, keeping the screen column the caret started from while it moves only that way. Home/End and everything else stay in file lines. With several carets each moves through its own rows (AtEachCaret), keeping its own screen column.
  • Multiple cursors and column select work while wrapped (#382): extra carets and Alt+Click go through the same row map as the main caret. A column-select box is taken in screen rows and cells, as the text is laid out (ExtendWrappedColumnSelection): _box holds (screen column, screen row) corners, every row in it gets a caret, and a continuation row of the same line gets one of its own. Shift+Home/Shift+End take the box to the row's edges, not the line's. Adding a cursor above/below is by file line. The status bar shows Column select before Wrap when both are on.

Syntax highlighting (#21)

  • Engine is TextMateSharp (VS Code grammars; TokenizeLine carries per-line state, so re-lexing can start at the edited line). PrismSharp was rejected — the ports either crash under AOT or have no line state; see the exploration notes on #21.
  • Oniguruma is native. StaticLink.libonigwrap links it into the AOT binary so the release stays one file; without it publish drops libonigwrap beside the binary and the release archive (binary only) would ship broken. On macOS the static library prints ~14 ld: warning: ... built for newer 'macOS' version (13.0) than being linked (12.0) lines — expected, not an MSBuild warning.
  • Grammars and themes come from TextMateSharp.Grammars, re-packed into the embedded src/TuiCode.Syntax/Grammars.zip (~820 KB vs 6.7 MB uncompressed). Every grammar is bundled with its language-configuration.json, but only the four themes the editor uses (Dark+/Light+ and their bases, all from VS Code): the package's other themes have no license records. Don't use that package's RegistryOptions at runtime — referencing it roots the whole uncompressed assembly into the binary. GrammarBundle is our IRegistryOptions over the zip.
  • Bumping TextMateSharp: update the version in TuiCode.Syntax.csproj, TuiCode.Tests.csproj and the #:package line of scripts/update-grammar-bundle.cs, then GITHUB_TOKEN=$(gh auth token) dotnet run scripts/update-grammar-bundle.cs. The zip is reproducible, and GrammarBundleTests.Bundle_matches_the_TextMateSharp_Grammars_package fails if the zip and the package drift. The same run regenerates the grammar section of THIRD-PARTY-NOTICES.md from each package's cgmanifest.json, fetching upstream LICENSE files (and Apache NOTICE files) from GitHub at the pinned commit — hence the token. Review any WARNING it prints: a source with no license file upstream gets a placeholder note (currently the Razor grammar, whose repo is gone).
  • GrammarBundle.LanguageForFile matches exact file names (Dockerfile) first, then the longest known extension (bundle.js.map is JSON via .js.map). filenamePatterns globs and injection grammars aren't supported (TextMateSharp's own RegistryOptions doesn't do injections either).
  • Wiring: one SyntaxHighlighter singleton (one TextMate Registry, so each grammar compiles once) flows DI → EditorPart → EditorGroup → EditorTab, which gives its EditorTextView a LineTokenCache for the file's language. All three constructor parameters default to null, so tests that don't pass one get plain text.
  • LineTokenCache (TG-free) lexes top-down and lazily: a frame lexes only through the last visible row, within EditorTextView.SyntaxBudget (15 ms), and App.Invoke(SetNeedsDraw) continues on later iterations — so jumping to the end of a big file shows plain text briefly rather than freezing. An edit invalidates from the first changed line; a line whose start state still equals the one it was lexed with keeps its tokens, so re-lexing stops as soon as the states converge. Lines over 20,000 chars (VS Code's limit) aren't lexed. Each line gets a 100 ms TextMate time limit; a line that hits it (typically the first lines of a cold grammar, while Oniguruma compiles its regexes) keeps its partial tokens for now, and TokenizeThrough returns false so the next draw re-lexes it, up to LineTokenCache.MaxRetries times. TextMateSharp's StoppedEarly is internal, so an overrun is detected by timing the call.
  • The cache is reconciled with Snapshot on every draw, not on ContentsChanged (unreliable, see above). That's cheap because unchanged lines keep their string instance, so the prefix/suffix comparison hits string.Equals' reference fast path.
  • Colours are painted in DrawRow from the row's tokens, never stored in Cell.Attribute — TG's undo, paste, InheritsPreviousAttribute and WordWrap all rewrite cell attributes. Token offsets are UTF-16 chars, so the row walk accumulates Grapheme.Length, not cells (Syntax_colours_follow_characters_rather_than_cells). Only foreground and style change; the scheme's background stays. Tokens with foreground id 1 (SyntaxHighlighter.DefaultForeground, no theme rule) keep the scheme's text colour — Dark+ defines no default token colour, so id 1 resolves to #000000. Find highlights and selection still paint over syntax colours.
  • Theme: WorkbenchHost picks the token theme for the current UI theme (see Themes). A switch bumps SyntaxHighlighter.ThemeVersion, which makes every cache re-lex (token metadata encodes theme colour ids).
  • Associations pick a file's grammar: a pattern — an extension (.cs) or an exact file name (Dockerfile) — to a language id. GrammarBundle.Associations holds the built-in ones from the grammar packages; the user's, in ISettingsService.GrammarAssociations, win over them (SyntaxHighlighter.LanguageForFile). A value of plaintext (or an id no grammar has) turns colouring off. They persist to a dedicated ~/.tui/TuiCode.grammars.json (a flat JSON object), like the keybindings file.
  • User grammar packages live in ~/.tui/grammars/<name>/, in VS Code extension layout: a package.json whose contributes.languages / contributes.grammars declare them, plus the JSON grammar files it points at. Copying a VS Code extension's folder works as-is (checked with Even Better TOML). GrammarBundle.Load(fs, dir) reads them through IFileSystem at startup (a restart picks up changes) before the bundled packages, and registration is first-wins, so a user package overrides a bundled grammar scope, language id or extension. Only JSON grammars load (TextMateSharp can't read plist .tmLanguage or YAML); injectTo grammars register but aren't injected, as with bundled ones. Anything unusable — no package.json, bad JSON or wrong types, a missing or non-JSON grammar — is skipped and described in GrammarBundle.Problems, which the Grammars settings pane shows (the first problem, and how many more). A user grammar is untrusted input to TextMateSharp, which throws for broken ones either on load (e.g. invalid JSON) or on the first lexed line (e.g. an invalid regex): SyntaxHighlighter.CreateCache and LineTokenCache catch both and leave the file plain rather than crash a draw.
  • Settings → Grammars (GrammarAssociationsView) lists every association, built-in and user, filtered by pattern or grammar name. Enter opens GrammarPickerView for the row, Delete resets it to the default, and a filter that's a new pattern gets an Add "…" row first — for a bare word only when nothing else matches, since "Python" is likelier a grammar search than a file name. The row logic is the TG-free GrammarAssociationRows. Edits apply on Save: WorkbenchHost.ApplyGrammarAssociations updates the highlighter and re-infers every open tab.
  • cg (Change grammar) opens the same picker for the active tab; EditorTab.SetGrammar pins that tab so later association changes leave it alone (InferGrammar skips it). It deliberately has no default keybinding — users who want one bind it themselves.
  • The status bar shows the active tab's grammar after its path (path • C#, or Plain Text). Workbench refreshes both on ActiveTabChanged and EditorGroup.GrammarChanged, so switching tabs now also resets the message to that tab's path.
  • EditorDrawBenchmarkTests.Syntax_colouring_keeps_drawing_a_large_file_fast (Explicit): on a 50k-line C# file a frame costs the same ~14 ms with colours as without.

About dialog (#107)

  • tui opens AboutView. If the terminal supports sixel (WezTerm, iTerm2, tmux 3.6, Windows Terminal, foot…) it shows the artwork as an image. Otherwise (kitty, Ghostty, Terminal.app — TG 2.1.0 has no kitty graphics) it shows ASCII art.
  • SixelProbe.Detect sends DA1 once at startup (WorkbenchHost ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. SixelProbe.MeasureCell runs each time About opens, because the cell's pixel size changes when the window moves between a Retina and a non-Retina screen. We don't use TG's SixelSupportDetector: it asks CSI 16 t first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. MeasureCell sends iTerm2's OSC 1337 ; ReportCellSize and CSI 16 t together and takes the first answer. Detect also parses tmux's DA1 reply (…;4c), which TG's check misses.
  • iTerm2 reports sizes in points everywhere, its CSI 14 t reply included. Only ReportCellSize carries the scale (2.0 on Retina), so ParseIterm2CellSize multiplies by it (clamped to 1–4; absent in older iTerm2s, meaning 1). Sixels are drawn in device pixels, so ignoring it drew the About image at a quarter of its area (#333).
  • F12 Diagnostics runs MeasureCell too each time it opens and shows the size with the reply it came from (CellMeasurement.Source), or that none came and 10 × 20 was assumed (#335).
  • The cell size has to be exact: iTerm2 blanks every row an image touches, so an image that ends mid-row leaves a dark band (AboutImage.Fit rounds down to whole rows and Cover trims the top/bottom to match).
  • Encoding runs in the background and is cached per pixel size, so reopening shows the image immediately.
  • TG re-emits queued sixels on every output write and only rewrites cells whose contents changed, so AboutView.Dispose dequeues its sixel and sets ClearScreenNextIteration. Otherwise the image stays on screen after close.
  • The artwork is assets/about.png, baked into the embedded About/about.rgb.z (raw RGB, zlib) by dotnet run scripts/update-about-image.cs. We have no image decoder at runtime and don't want one in the AOT binary.

File icons (#119)

  • FileIcons (a DI singleton) picks each icon for the explorer tree, the Find results tree, the Review tab's tree, the Open dialog (OpenView) and the sample in Settings → File Icons. It raises Changed when the style changes, so views redraw live. Each view takes it as an optional constructor parameter, so tests that don't pass one draw plain names.
  • Styles are FileIconStyle: NerdFont (a coloured icon per file type), Emoji (📁/📂/📄, drawn two cells wide), Off, and Auto, the default. Auto becomes Nerd Font or Emoji depending on TerminalFontDetection, which runs lazily and only once.
  • No terminal reports its font, so detection is a best guess. Ghostty, WezTerm and kitty (0.36+) bundle the Nerd Font symbols. iTerm2, Windows Terminal, VS Code and Alacritty get their font read from their config: iTerm2's binary plist goes through plutil -convert xml1, and the other configs are JSONC or TOML. A font counts as a Nerd Font when its name contains "Nerd Font" or ends in NF/NFM/NFP. Any other terminal gets Emoji. Terminal-specific env markers (WEZTERM_PANE, KITTY_WINDOW_ID, …) identify the terminal when tmux has replaced TERM_PROGRAM. TERM_PROGRAM=vscode wins over markers leaked from the shell that launched VS Code.
  • Icons are drawn, never put in the display text. In a tree, IconDrawing.Prepend inserts the icon cells in DrawLine (InsertAt for an icon that belongs elsewhere in the row), so AspectGetter stays the bare name and type-to-jump still matches. In a ListView, IconListSource draws the icon, then hands the text to a ListWrapper<string>. The icon keeps the row's background, so selection still shows. Its foreground is the icon's dark or light colour, whichever suits the row's background.
  • FileIcons.ForChange is the Review tab's change mark (#320): nf-cod-diff_added/_modified/_removed/_renamed with Nerd Font, the A/M/D/R letter with Emoji, both green/amber/red/blue, and null with icons off. Its colour is the token theme's gitDecoration.<kind>ResourceForeground where set (one colour for both backgrounds), else a built-in dark/light pair.
  • The Nerd Font data is src/TuiCode.Icons/file-icons.tsv, generated from nvim-web-devicons (MIT, listed in THIRD-PARTY-NOTICES.md). It has about 700 exact file names and extensions, each with a dark and a light colour. To update it, bump commit in scripts/update-file-icons.cs, then run GITHUB_TOKEN=$(gh auth token) dotnet run scripts/update-file-icons.cs. Lookup follows devicons: an exact file name first, then each dotted suffix from the longest down (app.spec.ts tries spec.ts, then ts), all case-insensitive. Folder icons and the generic file icon are fixed in FileIcons, because devicons has none.
  • The setting persists to ~/.tui/TuiCode.settings.json, a flat object for settings TG doesn't own. It can't go in TuiCode.config.json: TG silently ignores that whole file, theme included, when it holds a key TG doesn't know.

Git (#61)

  • IGitCli (GitCli) shells out to git; no LibGit2Sharp, which would ship a native lib per platform. Each call runs with a 5 s timeout, and a missing git, a non-zero exit or a timeout comes back as a failed GitResult with a status-bar-ready message, never an exception. Null values mean "not in a repo" / "not in that revision".

  • Runs with LC_ALL=C so "not a git repository" can be recognised, and passes file paths as ./<name> with -C set to the file's folder, so git resolves the root-relative path itself (symlinked temp dirs, Windows separators). Revisions starting with - are refused so a typed ref can't become an option.

  • GitCli and GitHubCli share only CliProcess, the start / timeout / kill plumbing; each builds its own ProcessStartInfo (git passes -C, gh runs in the repo root).

  • Tests against a real repo live in GitCliTests and skip when git isn't on PATH. Anything above the wrapper should test against a fake.

  • Compare to revision (ctr, no default key) opens RevisionPickerView once GetRepoRootAsync finds a repo: a TextField filter over HEAD, the refs and the file's last 200 commits (RevisionList, pure: names by CamelHumps, commits by hash prefix or subject). HEAD is always the first row, so ctr then Enter compares against it. The rest of the list loads on a background task and fills in when ready. The selection starts on the row the filter names exactly (HEAD in any case), else the first row, so typing HEAD doesn't pick feature/header. Up/Down and PgUp/PgDn (a page of rows) move it from the filter field; Enter takes the selected row, or the typed text when nothing matches. The host resolves the revision and reads the file at it off the UI thread (WhenDone marshals back with App.Invoke), and an unknown ref or a missing file is an error in the picker, which stays open. The diff tab is titled with the revision (a short hash for a commit) and reads it once: an open tab for the same revision is just focused.

  • Git blame (Ctrl+G B, gb, Editor scope, #330) opens BlameView for the cursor's line: git blame --line-porcelain -L n,n, parsed by GitCli.ParseBlame. A dirty tab is blamed with --contents - and the buffer on stdin, so the line number is the one on screen; a line no commit has comes back as the zero hash and gets an Info alert instead of commit details. no such path from git means the file is untracked. Nothing to say (no repo, untracked, no git, a timeout, the empty row after the final line break) is one status-bar line and no dialog. The dialog closes when it loses focus (FocusLeft), leaving the keys wherever they went, so there's never an open blame to re-blame into.

  • On a committed line, Enter (or its hint) in the blame dialog opens the change that introduced it (#331): a DiffTab between two revisions, titled a.cs 4a91c0e^ ↔ 4a91c0e, with no Source and a RightLabel. Blame's filename and previous headers give the path in that commit and the parent's hash and path, so a rename shows the old name's content on the left, and a file the commit created (or a root commit, which has no previous) has an empty left side. Both sides are read with ShowRepoFileAsync before the tab opens. Revert, revert all and go to line are disabled there, so the hint bar only shows next/previous. Enter is bound on an uncommitted line too, as a no-op, so it doesn't press the focused Esc close hint.

Review tab (#126)

  • The sidebar's Review tab (ReviewView, Review/) lists what the current branch changes against the merge base of HEAD with the default branch (IGitCli.GetDefaultBranchAsync: origin/HEAD's target, else main, else master). git diff --name-status -M <merge base> compares with the working copy, so staged and unstaged edits show; untracked files don't. Files group under one node per folder path, as in the pitch mockup, not a nested tree.
  • Focus review (Ctrl+Shift+R, fr) shows and focuses it. It refreshes when the tab is switched to, when the sidebar is shown on it, on the command while showing, on file save and on opening a folder; hosted, it loads on a background task like the Find tab.
  • Enter on a file opens it and a diff tab against the merge base, titled with the base branch, e.g. a.cs ↔ origin/main. The left side is read with ShowRepoFileAsync (repo-relative, so a rename's old path works even when its folder is gone), and its LeftKey is <merge base>:<path>, so it never matches a ctr diff against the same branch name.
  • Stepping through the review (#181). Next/previous change (Alt+Down/Alt+Up) past a review diff's last/first change moves on to the next file the tab lists, at its first (or last) change, and closes the diff it leaves so a review doesn't pile up diff tabs; the files themselves stay open, skipped ones included. A file whose buffer already matches the base is stepped over. At either end nothing moves and the status bar says Last change in the review / First change in the review. What makes a diff part of the review is DiffTab.Review, a ReviewSpot (the merge base, plus the file's place in the list) the host sets as it opens one — cts/cto/ctr diffs leave it null and so still stop at their last change. It also gives the status bar File 3 of 7, shown before Change 1 of 4, and the Review tab's selection follows whichever file is showing.
  • A deleted file (#182) opens as a DiffTab with no Source: the base version on the left, every row LeftOnly and so tinted as removed, nothing on the right, and a title ending (deleted). EditorGroup.CompareDeleted builds it, and no editor tab is opened — there's no file to open. With no buffer it takes its grammar from the file name (LanguageForFile) and its Settings from the group, and go to line (Enter) only says Deleted in this branch. DiffTab.File is the source's file when there is one, so a rename still retitles the tab. With no buffer to revert into, revert change (rc, #247) does the file-level version instead: EditorGroup.Restore opens the left side (DiffTab.LeftLines) as an unsaved tab at that path through EditorTab.Unsaved, dirty from the start with nothing on disk, so Ctrl+S is still what brings the file back and closing the tab leaves it deleted. The diff stays open, and a second rc focuses the tab it already opened. The hint bar reads restore rather than revert there (Workbench.DeletedDiffKeysHint), since that's what the key does.
  • The branch's PR (#183) comes from IGitHubCli (GitHubCli), which shells out to gh pr view --json … in the repo root, so TuiCode never handles a token and GitHub Enterprise works wherever gh does. It's fetched after the git load, in its own background step, so the file list shows before the header fills in. BranchReview.WithPullRequestAsync then relists the files against the merge base with the PR's base — resolved as origin/<base> first, then <base> — unless git's default branch already is it.
  • Open pull request (#184) (opr, no default key) lists the repo's open PRs and reviews one in its own worktree. The list is fetched before the picker opens (IGitHubCli.ListPullRequestsAsync: gh pr list --json number,title,author,headRefName,isCrossRepository, plus a second --search review-requested:@me pass whose numbers flag the ● rows — gh resolves @me, so a review requested of one of your teams counts), so a missing gh or an empty repo is one status-bar line rather than an empty dialog. PullRequestPickerView is then RevisionPickerView's shape over an already-loaded list (PullRequestList, pure: case-insensitive substring of the number, title or author).
  • Enter checks the PR out in ../pr-<n>, next to the repo root: IGitCli.AddWorktreeAsync (git worktree add --detach, reused when that path already is one of the repo's worktrees) and then gh pr checkout <n> in it, so gh does the fetch and makes the branch — which is what forks need, and what lets gh pr view there find the PR again. The picker shows Checking out #<n>… meanwhile, refuses a second Enter, and stays open showing whatever failed. Then Workbench.OpenFolder switches to the worktree (restoring its tabs, #13) and fr shows the Review tab.
  • Unless one of the repo's worktrees already has the PR's branch checked out (IGitCli.FindWorktreeAsync, from the same porcelain listing): that one is opened as it is, since git refuses to check a branch out twice and gh pr checkout would only fail. A PR from a fork is never matched that way — isCrossRepository leaves GitHubPullRequestSummary.HeadBranch null, because a fork's branch name names nothing here and main would match the main checkout.
  • Both of those take minutes, not the usual seconds: GitCli and GitHubCli each give the checkout its own far longer timeout.
  • Whether a path is already this repo's worktree is decided by git, not by string arithmetic: git worktree list --porcelain reports real paths, so the candidate goes through rev-parse --show-toplevel --show-prefix (an empty prefix means it's the worktree's root, not a directory inside one) before the comparison. Under macOS's /var → /private/var symlink the two spellings would otherwise never match, and a subdirectory of the main checkout would wrongly count as its own worktree.
  • Open worktree (#359) (ow, no default key) lists the repo's other worktrees from IGitCli.GetWorktreesAsync (git's order, bare and prunable entries left out, paths through GetFullPath), less the one GetRepoRootAsync names for the open folder, so a subfolder still hides its own worktree. It's registered ungated so that outside a repo it can say Not in a git repository.; that, no other worktrees and a git error are each one status-bar line rather than a dialog. Rows and filtering are the pure WorktreeList; Enter is Workbench.OpenFolder.
  • Submit review (#187) (sr, no default key) posts a review on the branch's PR: an OptionSelector of the three verdicts (Comment first — approving shouldn't be one stray Enter away), a word-wrapped TextView summary, and the two hints. The hints are the buttons — NoDecorations/NoPadding Buttons with no hotkey of their own, so Ctrl+Enter submit · Esc cancel reads as a hint line and clicks like a button, rather than repeating a Submit / Cancel pair above it. gh pr review <n> --comment|--approve|--request-changes [--body …], with an empty summary left out rather than sent as one. Comment and Request changes are refused in the dialog without a summary; Approve isn't. Like opr, the PR is looked up before the dialog opens, so no PR and no gh are one status-bar line. A refused review (approving your own PR) keeps the dialog, its text, and gh's first stderr line in the alert at the foot; a submitted one closes it and the status bar says Review submitted on #<n>. The summary's TabKeyAddsTab is off, so Tab leaves it for the hints instead of typing a tab; its WordWrap is on, so a long summary wraps in the box (the caret stays out of the frame) and still posts as one line.
  • The header is four stacked Labels, an Overview Button and a Line, packed by ReviewView.LayoutHeader into however many rows have something to say, with the file list below: #<n> <title>, <base> ← <head> (or <branch> ← <base> (no PR)), ✓ 11 ✗ 1 ● 2 checks (zero counts left out), and a faint hint when the PR lookup failed. Each line is held raw and cut to the pane's width with an ellipsis as it's laid out (#244) — a Label wider than the sidebar word-wraps onto a row the header never draws, so the end of a long branch name or hint would simply vanish. View.GetAttributeForRole isn't virtual in TG 2.1.0, so the hint's faint style comes from its GettingAttributeForRole event, which only sticks with Handled = true.
  • The Overview tab (#185). The Overview button opens #<n> Overview in the editor group: the PR's title, author and description, then each comment headed <author> · <date> under a --- rule, built by PullRequestOverview (pure). It's fetched when opened (IGitHubCli.GetConversationAsync: gh pr view <n> --json …,comments), and whatever gh says instead becomes the tab's own text, where a long error can be read in full. Opening it again focuses the tab it already has; nothing is refetched.
  • The button is the header's only focusable part: Up from the top of the file list selects it and Down goes back, and it's only there while there's a PR to open. pro (no default key) opens the same tab from anywhere, loading the review first when the tab hasn't been shown yet.
  • A document is a third kind of tab beside EditorTab and DiffTab: DocumentTab, a TG Markdown view over text that isn't on disk, tracked by EditorGroup under the path that names it (<repo root>/#<n> Overview, which also titles it). The view renders the Markdown and wraps it to the tab's width, and brings its own scrolling, selection and Ctrl+C; there's no editing, no Ctrl+F and nothing in the folder's reopen list (#13), since there's no file to reopen.
  • DocumentTab renders through ScrolledMarkdown, a Markdown whose OnDrawingContent reports the drawn region from the viewport, not the content origin. TG 2.1.0 reports ContentToScreen(Point.Empty), which a scrolled view puts above the tab: TG then excludes those rows from the clip of whatever draws next, and the neighbouring tab's header title is blanked until you switch back to it (DocumentTabTests.Scrolling_the_document_leaves_the_neighbouring_tabs_header_alone). Drop the subclass once upstream anchors the region to the viewport.
  • Review threads (#186) come from GraphQL (IGitHubCli.GetReviewStateAsync: gh api graphql, with gh's own {owner}/{repo} placeholders), because REST doesn't say whether a thread is resolved. They're fetched after the PR, in their own step, so the file list and the diffs are there first; ReviewView.ThreadsLoaded then hands them to the diffs already open. A thread with no line on the head side — outdated, or on the base side, whose line numbers are the base file's — counts as outdated and is left to the Review tab.
  • In the diff, a thread is one or more rows of its own between the rows of the diff: DiffTab keeps a display-row list where each row is a row of the diff or a row of a thread under one, so Up/Down step onto threads while change navigation, CurrentBufferLine and CurrentChange map back to the diff's own rows and ignore them. Enter (GoToChangeLine) expands or collapses the thread on the current row before it considers going anywhere. ReviewThreadRows (pure) decides what each row says: collapsed, ┃ <author>: <first line> with (<n> replies) at the right; expanded, every comment under its <author> · <date>. Thread rows are tinted with editorCommentsWidget.rangeBackground from the token theme, so a comment stands apart from the added/removed tints and the current row; a resolved thread is drawn faint over it.
  • Draft line comments (#188) (cc, no default key) are kept locally and posted with the review, as the pitch decided. cc on a row of a PR's diff drafts a comment on the head (right) side: a row with only a left side says Comment on a line on the right, and outside a PR's review diff it says Open a file from the Review tab to comment. The file has to be what GitHub has at the PR's head (GitHubPullRequest.HeadSha, from gh pr view --json headRefOid) — the buffer is compared with git show <head>:<path> before the dialog opens, since a comment on lines that have moved lands somewhere else on GitHub. CommentView is an InputView with [ Add ] / [ Cancel ] (and [ Delete ] when Enter reopens an existing draft), Ctrl+Enter to add and Esc to cancel; an empty comment is refused in the alert at its foot. In the diff a draft is one row, ┃ Draft: <first line>, under the line and under any threads on it, drawn like a thread row and stepped over by next/previous change.
  • DraftComments holds the drafts on one PR and persists them to ~/.tui/reviews/<repo>-<hash of its path>-<n>.json, so they survive a restart and two checkouts of the same PR don't share them. The Review tab says Draft review: <n> comments at its foot and the sr dialog <n> draft comments will be posted with it.. Submitting posts them with the verdict in one call: gh pr review can't carry line comments, so a review with drafts goes to gh api --method POST repos/{owner}/{repo}/pulls/<n>/reviews with the body on stdin (CliProcess writes it after the output reads have started, so a long review can't deadlock). They're cleared only once GitHub has them — a refused review keeps them, since they're all that's left of it.
  • Replying to a thread (#189) is the same cc, on a thread row of the diff or on an outdated thread's row in the Review tab. GitHub's API can't hold a reply in a pending review, so it's posted at once rather than drafted: CommentView(GitHubReviewThread) is the same dialog titled Reply to <author>, with [ Reply ] / [ Cancel ] and no Delete, and the host posts through IGitHubCli.ReplyToThreadAsync (gh api --method POST repos/{owner}/{repo}/pulls/<n>/comments/<id>/replies, the body on stdin). A refused reply keeps the dialog, its text and gh's first stderr line, like sr; a posted one closes it and the status bar says Replied on #<n>. GitHubReviewThread.ReplyToId is the thread's first comment as REST numbers it, read from the GraphQL query's databaseId — the reply endpoint is REST-only, since GraphQL has no resolved flag and REST has no thread id. A thread GitHub gave no id for is one status-bar line. Resolved and outdated threads take a reply like any other; no head-commit match is needed, because a reply names the thread, not a line.
  • The posted reply is put on its thread in place (DiffTab.ReplaceThread, ReviewView.ReplaceThread) rather than refetching every thread, so the reply count goes up without reopening the diff. The dialog is closed before the Review tab is rebuilt: a tree rebuild under an open modal drops TG's focus and the Tabs then picks a different active tab. An outdated-threads document tab already open isn't rewritten — OpenDocument only focuses what it has.
  • In the Review tab, ReviewTree.Build takes the threads as well as the changes: a file carries the threads on it and shows a badge — a count of the threads still open, or of all of them once they're resolved — which follows the name in ReviewRow.Display (the aspect, so type-to-jump sees it) and in the drawn row. Heading the count is a chat icon from FileIcons.ForThreads (nf-md-chat / nf-md-chat_remove_outline, or 💬 / 💭), which ReviewView.DrawFile draws into the row in DrawLine; with icons off, Display writes ● / ○ in its place instead. Icon and count both take ReviewFileNode.BadgeStyle (bold while a thread is open, faint otherwise). The header gains <n> threads, <m> unresolved, and a file with outdated threads gets a ReviewOutdatedNode child that opens them in a document tab like the Overview's, with a ReviewThreadNode under it per thread (<author>: <first line>) so cc has one to aim at (#189); Enter on either opens that tab.
  • Viewed files (#396) are GitHub's own Viewed checkbox, read in the same GraphQL query as the threads (files { path viewerViewedState }, keyed by the new path) into BranchReview.Viewed, which stays null without a PR. Toggle viewed (tv, Space, CommandScope.Review) on a file row calls IGitHubCli.SetViewedAsync (markFileAsViewed / unmarkFileAsViewed, naming the PR by the node id gh pr view --json id gives), and the tab shows the new mark only once that succeeds; a refusal is gh's first stderr line in the status bar. It's disabled on folder rows and without a PR, where Space does what it did before. A viewed row gets ✓ (FileIcons.ForViewed, nf-cod-check) after its name, before any thread badge, and its name and change mark go faint. DISMISSED (viewed, then changed) counts as not viewed. The foot says Viewed n of m (BranchReview.ViewedLine) above Draft review: …, laid out by ReviewView.LayoutFoot. Both the threads and the files are the first 100 GitHub lists.
  • The change mark (#320) is drawn in front of each file row by ReviewView.DrawFile, never put in ReviewRow.Display, so the row's text is the file name and typing one jumps to it as in the Explorer. Its glyph and colour come from FileIcons.ForChange over SyntaxHighlighter.EditorColors; with icons off (or no FileIcons) it's the plain letter, so the row reads as it always did. The file's type icon (#321) sits between the mark and the name, and folders get the Explorer's folder icon. A deleted file's name is faint as well, wherever the mark is coloured. ApplyTokenTheme redraws the tab on a theme switch.
  • Line counts (#393) come from IGitCli.GetLineCountsAsync (git diff --numstat -z -M against the same merge base as --name-status), keyed by the new path. They load in their own step after the file list shows, and again when the PR's base relists it; a failure just leaves them off. ReviewFileNode.Counts decides what shows (+N −M; only +N for an added file, −N for a deleted one, nothing for a pure rename, a faint bin for a binary), in FileIcons.ChangeColors' added and deleted colours. DrawFile rebuilds each file row so they sit at the viewport's right edge — TG pads the row's cells to the viewport before DrawLine, which the aspect can't do, since the tree draws its indent before it — and cuts the name with … before them.
  • The totals line (#394) sits under the branch line: BranchReview.TotalsLine is the listed file count plus the sum of every line count (5 files +214 −38), or just the file count while there are none. ReviewView.TotalsLabel overrides OnDrawingText to colour each +/− run like the file rows' counts.

Filesystem

  • All I/O through IFileSystem from System.IO.Abstractions; never call System.IO.File / Directory directly. IFileInfo.FileSystem plumbs the same instance through to EditorTab etc.
  • DI registers new FileSystem() singleton; tests build their own MockFileSystem.
  • EditorTab.Save appends a final line break if non-empty and not already terminated (VS Code files.insertFinalNewline; the Insert final newline setting turns it off). With Line endings on Auto (the default) it preserves the file's line-ending style, mirroring VS Code: DetectEol fixes the EOL on load and Save re-applies it. A non-empty file keeps its own style (first line break wins; LF if it has none); an empty buffer is treated as a new/blank file and takes the OS default (Environment.NewLine — CRLF on Windows), which is what a Ctrl+N file gets. Normalize is needed because TextView.Text re-joins lines with Environment.NewLine, so a CRLF file would otherwise become LF on Linux. In tests: assert exact bytes (\n / \r\n) for existing-file preservation, but assert Environment.NewLine for new/empty-file output (it's intentionally OS-dependent).
  • Ctrl+S never overwrites a newer file without asking (#267). EditorTab keeps a FileSnapshot of the file as it last saw it — mtime, length and the content it read — taken on load and again after every write, so EditorTab.DiskNow reports someone else's change, never our own. mtime and length are only a screen: on a difference the file is re-read and compared, because git checkout rewrites files byte for byte and a false alarm on every branch switch would be worse than the bug. A file that has been deleted isn't a change but DiskState.Gone (there's nothing left to overwrite) and Save just recreates it — #271's, below. The prompt (WorkbenchHost.ConfirmOverwrite) appears whenever the file changed, dirty tab or not: a clean buffer is still the older text, and writing it loses their work just the same (its second line says which case you're in). Which buttons it offers is #270's, below. Later slices of #133 hang off the same snapshot.
  • A tab says so the moment its file changes, without a keypress (#268). A save-time guard is too late to be kind, so DiskWatcher (Workbench/Files/) follows the open tabs' files through IFileSystem's IFileSystemWatcherFactory and DiskChanges marks the tab: ● name ⚠ , the name in the theme's Warning colour in the tab strip and the Explorer row, and one status line. The mark is DiskMark.For — nf-oct-alert under a Nerd Font, ⚠ otherwise — and EditorGroup.IconStyle keeps it in step with the file-icon setting the way GutterVisible does. The trailing space is deliberate: without it the mark sits on the tab's right border. No modal, no focus steal, no beep — you can keep typing through it, and #267's prompt still stands at the save.
    • One non-recursive watcher per distinct directory on the way from each open file up to the root (everything above the file's own directory is #271's, below), shared by every tab underneath it and torn down when the last of them closes. So the count is the open files' chains, not the size of the tree: tabs in one repo overlap almost entirely, and twenty files across four directories of a project cost six watchers. On Linux each is an inotify instance out of fs.inotify.max_user_instances (default 128, shared with every other app in the login session) — which is why the watchers stop at the chains, and why running out is survivable: a watcher that can't be created, or that raises Error, is dropped and logged, and the files under it fall back to the activation check below.
    • Events are debounced 250 ms and then verified by reading (EditorTab.DiskNow), which is what makes a truncate-then-rewrite report its finished state rather than the empty file in the middle, and what makes our own saves and a byte-for-byte git checkout mark nothing. WorkbenchHost.ScheduleFlush schedules it with App.AddTimeout, whose callback runs on the main loop — the hop back from the watcher's thread comes free with it.
    • MockFileSystem has no watcher of its own (it throws and tells you to supply a factory, but the property has no setter): WatchableFileSystem in the tests overrides it, so every rule above is asserted without a real disk or a Thread.Sleep.
  • A clean tab shows what's actually on disk (#269). On the same notice, a tab with nothing to lose takes the new text up by itself — no modal, nothing to confirm, and nothing on screen at all for a background tab; the one you're looking at gets ⟳ Reloaded <name> — changed on disk in the status bar. EditorTab.Reload reads the file once through FileSnapshot.ReadIfChanged (which shares its single pass over the file with DiskNow), keeps the cursor's line and column clamped to the new file — read before the text is replaced, since MoveCursor clamps — resets the gutter's baseline, re-detects the line-ending style, and clears the undo history, which EditorTextView's Text setter already does. A dirty tab is never reloaded behind your back: it keeps its edits and #268's ⚠.
    • A directory whose watcher failed catches up on activation, which is graceful degradation rather than a second mechanism: DiskChanges runs the same routine on EditorGroup.ActiveTabChanged, and only when DiskWatcher.IsWatching reports a gap — a directory anywhere above that file with no live watcher, or a directory that has itself gone, since a watcher over one that isn't there can't be hearing anything. A fully watched chain has already told us, so switching tabs re-reads nothing. That's also why EditorGroup.Track raises TabsChanged before Add: the new tab's directory has to be followed before anything can react to it being active, or every file you open would be re-read the moment it appears.
  • A way out of the conflict, not just a warning (#270). The prompt's buttons are [ Compare ] [ Overwrite ] [ Reload ] [< Cancel >], Cancel focused. Compare leads because it's the only one that decides nothing; then take mine, then take theirs. Compare runs the same cts diff, buffer against what's on disk now, and Reload is #269's EditorTab.Reload — which is why that clears _dirty as well: asked for over unsaved edits it drops them, so ● and ⚠ both go and the next Ctrl+S is silent. It goes through DiskChanges.Reload rather than the tab directly, so the Explorer's mark is put back in step too. It doesn't confirm a second time: you already chose it in a dialog.
    • ConfirmView takes a ConfirmChoice per way out — a label and what taking it does — and appends Cancel itself, laid out with Pos.Align(Alignment.Center), so a row of one to four centres itself inside the 60-column modal. It raises Chosen rather than running the action itself, because the host has to close the modal first: removing a focused modal hands focus back into the editor group and a Tabs switches Value to whichever tab takes focus, so a diff opened before the close wouldn't stay the active one (#191).
  • A deleted file doesn't vanish from under you (#271). ⚠ and ⊘ are one state with three values, not two flags: DiskState (Unchanged / Changed / Gone), which FileSnapshot.StateOf answers and EditorTab.DiskMarker shows. A file that has gone is never reloaded — with nothing on disk the buffer is the only copy of it left — so DiskChanges.Act takes the Gone branch before it asks whether the tab is dirty, and a dirty one keeps ●, ⊘ and every edit. Ctrl+S writes the buffer straight back with no modal: there's nothing on disk to overwrite, so there's nothing to ask about, which falls out of DiskNow being Gone rather than Changed. Save creates the file's directory first, since a git checkout that took the file usually took its directory too.
    • A rename away is a deletion as far as a tab is concerned — we don't follow the file to its new name. FileSystemWatcher reports it as Renamed carrying the new path in FullPath, so DiskWatcher.OnRenamed notes OldFullPath as well; watching only FullPath would miss it entirely. The same handler is what hears an editor that saves by renaming a temp file over ours.
    • The come-back is the same routine in reverse. Check the branch out again and Act runs on the create: a clean tab reloads as #269 does, or just drops ⊘ when the file came back byte for byte (there's nothing to take up, only a marker to clear — the one case where Reload returning false still changes what's on screen), and a dirty one becomes ⚠ as #268 does.
  • Closing a dirty tab asks first (#409). Ctrl+W / cf on an EditorTab with unsaved edits opens a ConfirmView: [ Save ] [ Don't save ] [< Cancel >], Cancel focused. Save goes through the same #267 check as Ctrl+S; over a changed file the conflict prompt appears and only Overwrite closes the tab. Diff and document tabs close without asking, and the command is enabled on any tab, document tabs included.
  • Reload from disk on demand (#272) (rd, Editor scope, no default key) is DiskChanges.Reload with evenIfUnchanged, so a dirty tab over a file nobody touched still drops its edits. A clean tab just reloads; a dirty one confirms first through ConfirmView, Cancel focused; a Gone file is refused, since the buffer is the only copy.
    • A watcher hears nothing about its own directory being removed, so every directory above an open file is watched as well, all the way to the root, with NotifyFilters.DirectoryName in the filter, and DiskWatcher maps a directory event onto every file it follows beneath it. Without both halves, rm -rf <dir> around an open file is completely silent on macOS — no file event, no error, the FSEvents stream simply reports the directory and a non-recursive watcher rooted there filters it out. Whichever directory in the chain goes, the watcher one level above it is the one that says so, which is why stopping at the parent wasn't enough. A directory that holds no open file of its own asks for DirectoryName and nothing else, so passing through $HOME costs no file events.
    • An in-app delete leaves no marker: Workbench.Delete closes the tabs through EditorGroup.CloseUnder first, so by the time the watcher's event is flushed the path is neither followed nor open, and both the flush filter and the tab lookup drop it.
  • Save all (#410) (Ctrl+Shift+S, saf) saves every dirty editor tab the way Ctrl+S would, skipping diff and document tabs, and reports Saved N files or No unsaved changes. A tab whose file changed on disk isn't counted: the first one gets #267's prompt, and any others stay dirty for the next Save all. Terminal.app collapses the key onto Ctrl+S, so saf is the way in there.

Editor settings (#14)

  • Settings → Editor holds indent size (1–8, also the display width of a tab), spaces vs tabs, line endings (Auto / LF / CRLF), insert final newline and wrap long lines, as one EditorSettings record on ISettingsService.Editor. They persist to TuiCode.settings.json beside the file-icon style, each key written only when it differs from the default, and each read falling back to its default on its own. They apply on Save: WorkbenchHost hands them to EditorGroup.Settings at startup and whenever Settings closes, and the group passes them to open and future tabs, like GutterVisible.
  • Tab and Shift+Tab are the workbench commands Indent lines and Outdent lines (#386, EditorTextView.Indent.cs), and TG's own NextTabStop/PreviousTabStop on the view run the same code, so the keys behave alike whether or not the workbench binding is there. If any caret has a selection across lines, Tab indents every line under every caret by one level (Indent size spaces, or a \t), leaving empty lines empty; otherwise it inserts at each caret as before, filling to the next tab stop with spaces. Shift+Tab always works on whole lines, with or without a selection: it trims each line's leading whitespace from its end back to the previous tab stop, so mixed tabs and spaces keep their order. The lines are RowBlocks, as for move/duplicate line, so a selection ending at column 0 leaves that line alone. Points at column 0 stay there, so a selection of whole lines remains one. The find bar's layered scope takes Tab and Shift+Tab before the Editor scope sees them.
  • Toggle line comment (Ctrl+/, tlc, #387, EditorTextView.Comments.cs) works on the same RowBlocks as Tab. If every non-blank line under every caret starts with the marker after its indentation it removes the marker and one space, otherwise it adds marker + " " at each block's smallest indentation; blank lines are skipped. The marker is SyntaxLanguage.LineComment, read from the bundled package's VS Code language-configuration.json (user grammar packages don't supply one yet). A language with no line comment (HTML, XML, Markdown, CSS — #389) wraps each line in its SyntaxLanguage.BlockComment instead: open + " " at the block's indentation and " " + close at the line's end, unwrapped only when every non-blank line starts with one and ends (before trailing whitespace) with the other. A language with neither says <name> has no comment syntax.
  • Enter starts the new line with the whitespace left of the caret on the line it was pressed on (#385), wrapping TG's NewLine in EditorTextView.NewLineKeepingIndent. Whitespace it put on an otherwise empty line is tracked by the List<Cell> itself, since TG edits lines in place and row numbers shift under other carets, and trimmed once no caret is on it and it still holds only that whitespace. Inside an edit (Edit, EditAtPrimary) the trim joins that undo step; on a plain move (OnUnwrappedCursorPositionChanged, MoveCaret) it's folded into _recorded so it isn't a step of its own.
  • LF or CRLF converts a file's endings on its next save. Only Auto preserves them.

Document info (#123)

  • DocumentStats (TG-free) counts lines, words (wc -w's runs of non-whitespace) and characters for the buffer or the selections at every caret, via EditorTab.CountDocument/CountSelection. It reads LineSnapshot strings and splits graphemes with StringInfo, which has to agree with TG's cells, the unit of Col and of the range columns (CountDocument_counts_characters_in_the_same_unit_as_columns).
  • di (Show document info, no default key) opens DocumentInfoView for the active tab: its path relative to the open folder, grammar, the line ending Save will write, size on disk (read fresh), and the counts. The Selection column appears only when something is selected. The counts are taken once when it opens; it doesn't refresh live.
  • While text is selected, the status bar's position slot adds the selection's character count (Ln 12, Col 5 (34 selected), or 3 selections (96 selected) with several carets). Workbench.ShowCursorPosition asks for it every iteration, so EditorTab.CountSelection caches its result against the caret ranges and an edit counter bumped in OnEdited, and recounts only when either changes.

Terminal compatibility

  • TerminalFlowControl runs stty -ixon -ixoff on Unix so Ctrl+S reaches the app. Restored on dispose. Mandatory.
  • Three-modifier combos require a capable terminal: iTerm2 / Ghostty / WezTerm / Alacritty on macOS; kitty / foot / GNOME Terminal with modifyOtherKeys on Linux. macOS Terminal.app strips them and collapses Ctrl+Shift+letter onto Ctrl+letter.
  • Option+←/→ never reach the app as Alt+Left/Alt+Right on macOS. Both terminal integrations map them to \e[1;5D/\e[1;5C — Ctrl+Left/Ctrl+Right — so Option gives word-wise movement in the editor, as macOS users expect. Option+↑/↓ are left alone, which is why Alt+Up/Alt+Down work. Don't give a command Alt+Left/Alt+Right as its default (Revert change took Ctrl+R instead, #245).
  • Alt+Click (add/remove a cursor, #106) doesn't reach the app in iTerm2: holding Option there suspends mouse reporting so the user can select text. iTerm2 reports Cmd as the mouse "meta" modifier, which TG decodes as MouseFlags.Alt, so Cmd+Click works instead. The Getting Started dialog (HelpView) says so.
  • Driver selection (#82): DriverSelection.Resolve in Program.cs picks the TG driver and plumbs it into Application.Init(driverName). TG 2.1.0's DriverRegistry registers exactly three names — windows (Win32 Console API), dotnet (.NET System.Console), ansi (escape-sequence/kitty) — and TG's auto-select resolves to ansi on Windows, which mis-decodes kitty key events there (Backspace / Shift+letter / Ctrl+digit drop) despite kitty being fully negotiated. So on Windows we default to windows (IEnvironment.IsWindows), which reads keys via the Win32 Console API and decodes correctly in Windows Terminal; off Windows we pass null (TG auto). Precedence: --driver <name> / --driver=<name> flag > TUICODE_DRIVER env var > per-OS default; a blank override falls through to the default. The name is pass-through — TG validates it (throws ArgumentException on unknown, e.g. the v2win/v2net names from TG 2.2+ don't exist here). F12 Diagnostics shows the active driver (_app.Driver.GetName()). Known gaps the driver can't fix: Ctrl+0 is eaten by the terminal's own zoom-reset (#81) — which is why it's no longer the sidebar-toggle key (use the ts mnemonic); WezTerm-on-Windows decodes poorly under every driver (tracked for the TG 2.2.x upgrade). Tests force ansi via the same driverName ctor param (the real WindowsDriver blocks on console input headless), so the windows default only ever runs through Program.cs, never in CI/tests.

Command line (#219)

  • tuicode [--flags] [<path>...]. Every argument that doesn't start with -- is a path; everything else belongs to another parser (DriverSelection, TerminalIntegrationCli, --smoke/--smoke-syntax). --driver is the only flag whose value is a separate argument, so StartupArguments skips the one after it — --driver ansi never opens a file called ansi.
  • UsageCli (Configuration/) answers --help / -h (#264): usage block to the injected TextWriter, exit 0, ahead of every other parser so nothing has drawn on the terminal. Its text is a literal, wrapped to 80 columns; TerminalIntegrationCli.Flags and DriverSelection.Flag are public so a test can assert every flag the parsers accept appears in it. Add a flag, add its line — and document only what exists.
  • StartupArguments.Resolve (Configuration/) is pure and takes IFileSystem, like DriverSelection. Program.cs runs it before Application.Init, so the create prompt and any error reach a terminal nothing has drawn on yet: a path that can't be created prints tuicode: permission denied: <path> to stderr and exits 1. Opening it is deferred to the first loop iteration (WorkbenchHost.OpenWhenRunning, which then calls Workbench.OpenStartupTarget: the folder, then the file). Resolving early and opening late are separate for a reason — before the first layout, Editor.Open's focus move hands the keyboard back to whichever tab OpenFolder restored (TG 2.1.0 leaves HasFocus set on it, and TG's ancestor focus-restore then picks it over the tab we just opened), so the file you named ends up a background tab with the cursor move applied to the wrong buffer; and there's no viewport yet to scroll the position into.
  • A missing path is never created silently — CreatePrompt.Ask asks Create file? [y/N] on the terminal, so a mistyped filename doesn't leave an empty file behind. Anything but yes (a redirected stdin at EOF included) exits 0 without booting. What gets created follows the rule Ctrl+N uses (FilePaths.IsDirectoryPath): a trailing slash means a folder, anything else a file (so tuicode Makefile works), and intermediate folders come with it.
  • Root rule: the folder you ran from stays the workspace when it contains the file you asked for; otherwise the file's own folder becomes it. A folder argument is always the workspace, remembered tabs and all (#13).
  • Several paths open as tabs in the order given, the first one active (#266) — what a shell glob or a multi-file grep hands you. Only the first path roots the workspace, by the rule above; the rest are just tabs and may live outside the root, and a folder anywhere but first opens nothing and doesn't re-root. StartupArguments resolves the list all-or-nothing: it classifies every path, asks about every missing one, and only then creates them, so declining the last leaves the earlier ones uncreated and one path that can't be created leaves you at the shell rather than in a half-opened session. Duplicates collapse ordinally, first occurrence and its position winning — EditorGroup.OpenOrFocus would too, but the parser is where it's testable. OpenStartupTarget opens them in order and then revisits the first: opening leaves the last tab active, and the reveal only means anything for the tab with a viewport.
  • path:line[:column] — the shape grep -n, stack traces and CI logs print — puts the cursor there (#265). PathPosition.Split takes the suffix off before StartupArguments resolves anything, and the whole "position or part of the name?" question is settled there, against IFileSystem, so it's all testable on MockFileSystem. Three rules decide it: a path that exists wins outright (weird:42 opens as itself if it's there — POSIX only in practice, since a Windows file name can't hold a colon, which is why that test is Assert.SkipWhen'd there); a suffix is a position only when it's all digits, so :abc, :-1 and a Windows drive letter stay part of the name; and a line of 0 or less is no position, not an error. Positions are 1-based as printed and converted at the boundary — EditorTab.MoveCursor is 0-based and already clamps, so a line past the end lands on the last one. A folder argument drops any position, and so does a path that had to be created: there's no cursor to place in the first, and the second is created as typed, :42 and all, on the grounds that nobody gives a line number for a file they know isn't there. The line is centred in the view, as Go to line does it.

Terminal integration

  • Each supported emulator (iTerm2, WezTerm) implements ITerminalIntegration and is registered as a singleton in Program.cs. Consumers (Settings UI + CLI) inject IEnumerable<ITerminalIntegration> straight from DI — no separate registry.
  • Iterm2Integration writes ~/Library/Application Support/iTerm2/DynamicProfiles/tuicode.json via IFileSystem; tests pass MockFileSystem + FakeEnvironment. Stable GUID; staleness detection via a TuiCodeIntegrationVersion marker inside the JSON. Bound Hosts lists both &TuiCode* and &tuicode* so the brew-renamed binary still matches (iTerm2's matcher is case-sensitive).
  • WezTermIntegration writes ~/.config/wezterm/tuicode.lua only — it deliberately doesn't touch wezterm.lua (WezTerm users treat that file as personal config). The user pastes a one-liner (require 'tuicode'.apply(config)) themselves; the snippet is surfaced via ITerminalIntegration.PostInstallInstructions, rendered in the Settings panel and printed by the CLI installer. The module registers a tuicode key table and a user-var-changed handler. WorkbenchHost emits OSC 1337 SetUserVar=TUICODE_ACTIVE=1 on startup (after _app.Init) and =0 on shutdown (post-_app.Dispose, pre-_flowControl.Dispose) to push/pop the key table — unconditional, since terminals that don't grok OSC 1337 strip it silently. Staleness marker (-- TuiCodeIntegrationVersion: N) lives in the module. tmux passthrough caveat: nested sessions need set -g allow-passthrough on for the OSC to reach WezTerm.
  • CLI surface in TerminalIntegrationCli — --install-/--uninstall-/--list-/--check-terminal-integration[=id]. Program.cs runs it before TG init and exits on hit; the --check flag returns 0/1/2 for installed/stale/not-installed.
  • Settings UI: TerminalIntegrationPickerView shows only the detected terminal (per #59). Buttons act on ITerminalIntegration directly — no staging via ISettingsService.Save, because the write is to an external app's config, not a TuiCode setting. Rendering decisions are split into the pure TerminalIntegrationPanelState.Build so unit tests don't need TG.

Sidebar width (#209)

  • SidebarSizing (Abstractions) holds the limits and the clamp: default 30, floor 15, 5 a step, and a maximum of max(15, terminalWidth - 40) so the editor keeps 40 columns — except on a terminal too narrow for both, where the sidebar's floor wins.
  • Workbench keeps two widths. SidebarWidth is what the user asked for; DrawnSidebarWidth is that clamped to the terminal, re-computed in OnSubViewsLaidOut so shrinking the window never starves the editor and widening it back restores the choice. The clamp never writes back to SidebarWidth.
  • Widen sidebar / Narrow sidebar (ws / ns, unbound by default) nudge from what's on screen and persist through ISettingsService.SidebarWidth, so they save on each press — the only commands that write settings outside the Settings dialog.
  • Dragging the right border resizes it too (#252). WorkbenchHost.OnAppMouseEvent is the mouse's single subscription, the twin of OnAppKeyDown: App.Mouse.MouseEvent fires before any view sees the event and Handled short-circuits the whole of RaiseMouseEvent, which is what keeps a drag off the editor's cursor, the trees' selection and focus. A drag starts only where TG reports the deepest view under the pointer as the sidebar's own BorderView — so a modal over that column is ruled out for free — and only on the right border's column. Moves follow the pointer without saving; the release is the one commit point, persisting and reporting the width exactly as the commands do. TG 2.1.0 does have ViewArrangement.RightResizable, but its Arranger takes focus, enters arrange mode with its own buttons and knows none of these clamps, so it isn't used.
  • Settings → Interface is the same value as a NumericUpDown<int> (15–80). The spinner can't know the terminal, so its maximum is fixed; a wider width chosen by the commands on a big terminal is still stored and loaded, and only the floor is validated on load.

Settings & persistence

  • DefaultSettingsService is a thin wrapper around TG's static ConfigurationManager / ThemeManager. Theme getter/setter delegate straight to ThemeManager.Theme; no backing field. Load() calls ConfigurationManager.Enable(ConfigLocations.All); Program.cs invokes it on the resolved service before constructing the App, so ThemeManager.Theme is in place when Application.Init() paints. ThemeManager.Theme is a TG-native [ConfigurationProperty(Scope = typeof(SettingsScope))] and persists as {"Theme": "Daylight"} at the JSON root of ~/.tui/TuiCode.config.json. Save() writes that format manually (TG exposes no save API). Picker exposes only our bundled themes (see Themes).
  • Keybindings persist to a dedicated file ~/.tui/TuiCode.keybindings.json (read at DefaultSettingsService construction, written on Save()) — TG's serializer can't round-trip them. Diff style: { Keys, Label, Command }, prefix Command with - to remove. A removal only unbinds the key while it still runs that command, so a default that moves to another command (#362) isn't lost to an old removal. Boot: BindDefaults then ApplyKeybindings(settings.KeybindingOverrides). Picker calls WorkbenchHost.ApplyEditedBindings on save (recomputes diff from defaults so the file stays minimal).
  • A binding's canonical identity is its chord of raw KeyCodes, not its display string (#89). Key.ToString() is lossy — a three-modifier chord like Ctrl+Alt+Shift++ (0xD000002B) stringifies to "Ctrl+Alt+Shift++", which Key.TryParse can't read back (a TG round-trip bug), so a display-string identity made such chords un-bindable and crashed on save. So KeyBinding/KeybindingOverride carry an IReadOnlyList<Key> chord; KeyChord.Canonical (keycode-joined) is the identity used for binding, matching, diffing, dedup, and persistence, while KeyChord.Display (ToString()-joined) is UI-only and never parsed back. IKeybindingService exposes Key-list Bind/Unbind/CheckConflict as the canonical path; the string overloads remain as sugar for the hardcoded BindDefaults (safe literals). The picker feeds its captured List<Key> straight in — no stringify round-trip. Persistence writes "Keys": [<uint keycode>…] plus a decorative "Label" (the display string, ignored on load); load reconstructs each Key from its keycode via new Key((KeyCode)u), which is lossless.
  • Pre-#89 files are dropped, by design. An old { "Key": "<display>", "Command": … } entry has no "Keys" array, so LoadKeybindings skips it (the same per-entry resilience as #90). Custom bindings from before the format change are lost on upgrade and re-saved in the keycode format on the next edit — there is no in-place migration. The crash class #90's Bind-time try/catch guarded (an unparseable key string) is now unrepresentable, so that guard fires only on a bad command (e.g. empty); the loader's per-entry skip is where format/corruption tolerance actually lives now.
  • Workspace state (#13) — the files open per folder and the active one — persists to ~/.tui/TuiCode.workspaces.json via WorkspaceStateStore: a JSON array, most recently used folder first, capped at MaxFolders (30). The launch folder always wins (like code .); Workbench.OpenFolder (startup and the in-app Open) restores that folder's tabs, skipping files that are gone. Saves happen on every ActiveTabChanged (open, close, switch), so a crash loses nothing. Workbench nulls its folder while switching and on dispose so closing the old tabs isn't saved as that folder's state. Cursor positions aren't persisted yet.
  • Open recent folder (#357) (or, no default key) lists WorkspaceStateStore.Folders() as RecentFolderPickerView, less the folder open now and any that no longer exist; it only reads the file, so dead entries are hidden, not pruned. RecentFolderList (pure) builds the name + ~-shortened parent rows and filters them. Enter is Workbench.OpenFolder.
  • Open file path (#358) (opa, no default key; op would shadow opr) is a PathPromptView for a folder to open by its full path; FolderPath.Expand turns a leading ~ into the home folder and drops a trailing separator. A relative path, a missing folder or a file is No such folder: <path> in the prompt, which stays open; Enter otherwise is Workbench.OpenFolder.
  • Don't reach into ConfigurationManager / ThemeManager from feature code — go through ISettingsService. Tests use InMemorySettingsService.
  • Tests that mutate ThemeManager.Theme (e.g. DefaultSettingsServiceTests) must use [Collection("StaticConfiguration")] plus a local ThemeFixture to snapshot+restore the static. Keybindings are instance state on the service, so keybinding-only tests need neither.

Themes (#11)

  • We offer only our own themes (BundledThemes.Names: Midnight, Daylight, Turbo Pascal, Modern Borland). TG's built-ins can't describe the gutter or cursor. DefaultSettingsService.Load migrates a saved theme we don't ship (TG's Light → Daylight, anything else → Midnight, the default).
  • Each theme is two files that share a name. The UI half is TG theme JSON in src/TuiCode.Workbench/Themes/themes.json, handed to TG as ConfigurationManager.RuntimeConfig. TG merges Themes per theme, and the file sets no Theme key, so the user's saved choice still wins. The editor half is a VS Code-style token theme in src/TuiCode.Syntax/Themes/ (embedded as themes/<file>, checked before the zip). Its tokenColors colour syntax, and its colors carry what TG schemes can't: editorCursor.foreground and the gutter keys. BundledThemes.TokenThemeFor pairs them. Adding a theme means touching both files, BundledThemes, and the tests' theme lists.
  • Token themes may include others (midnight.json → dark_plus.json → dark_vs.json). TextMateSharp resolves only one level, so GrammarBundle.GetTheme flattens the chain itself, appending tokenColors and letting a theme's colors override its base.
  • Parts name their own schemes: SidebarPart uses Sidebar, StatusBarPart uses StatusBar; a missing scheme falls back to the SuperView's. Every bundled theme defines all of Base, Accent, Dialog, Menu, Error, Warning, Sidebar, StatusBar (a test enforces it). TG has no VisualRole for a warning, so Warning is read by name through WarningColour.On, which takes only its foreground and leaves whatever the row or tab header is drawn over alone.
  • Cursor colour: the editor uses the terminal's own cursor, whose colour TG never sets, so a terminal profile's cursor could vanish against the theme's background. WorkbenchHost sends OSC 12 with the theme's editorCursor.foreground at startup and on every ISettingsService.ThemeChanged, and OSC 112 on shutdown to restore the terminal's colour. Terminals without OSC 12 (Terminal.app) ignore it. A terminal profile we install must carry the colour too (ITerminalCursorColour): a profile switch reapplies the profile's colours over anything we sent (#299). F12 Diagnostics reads the colour back with OSC 12 ; ? (CursorColourProbe) each time it opens and shows it beside the one we asked for (#300).
  • Previewing a theme without touching your config: run the app with HOME pointed at a scratch directory and TUI_CONFIG='{"Theme":"Turbo Pascal"}' (TG loads that env var after the user file).

Wiring

  • Program.cs is the only DI consumer.
  • Workbench (the root Window) wires cross-part events in its ctor: explorer.FileActivated → editor.Open + tab.FocusContent + statusBar.SetMessage, editor.FileSaved → statusBar.SetMessage. Add new cross-part wiring here.
  • WorkbenchHost owns IApplication, the key intercept, and workbench-scoped command/keybinding registrations.
  • Parts (SidebarPart / EditorPart / StatusBarPart) are thin layout slots over feature views.
  • Logging is Microsoft.Extensions.Logging. Program.cs calls services.AddLogging() with no provider, so ILogger messages are captured through the abstraction but don't surface anywhere yet — picking the sink (file / status bar / diagnostics view) is tracked in #92. Inject ILogger<T> to record events (first use: WorkbenchHost logging skipped malformed keybinding overrides, #90); don't reach for the status bar to report background/diagnostic conditions.

Navigation

  • In-editor cursor navigation (Home/End/Ctrl+arrows + Shift selection variants) comes from TG's built-in TextView bindings — we don't bind these ourselves.
  • Ctrl+G is a chord family (#35): Ctrl+G L opens GoToLineView (1-based line[:col] input), Ctrl+G P/Ctrl+G N step back/forward through cursor history, and Ctrl+G B blames the cursor's line (see Git). EditorTab.MoveCursor(row, col) writes through TextView.InsertionPoint, which is a Point (Column, Row) — not an int offset. Out-of-range row/col clamp.
  • Every explicit jump ends in EditorTab.RevealLines (#200): go to line, go to symbol, Back/Forward, a Find result, tuicode file.cs:42 and Enter in a diff. It scrolls so the range's first line sits Reveal.Margin (2) rows below the top, or not at all when the range is already in view with that margin at both ends — so a hop to something you can see doesn't move the screen. Reveal (TG-free, in TuiCode.Editor) is the whole of the calculation and owns the margin the diff pane reads too. It runs after the cursor move, since TG's ScrollToMakeCursorVisible would otherwise have the last word, and does nothing before the first layout (height 0). Ordinary movement — arrows, PgUp/PgDn, find-next, next-occurrence — keeps TG's minimal scrolling. The gutter follows for free, off ViewportChanged.
  • A jump never carries a selection (#360): EditorTab.MoveCursor drops extra cursors and the selection before it moves, and Select (Find results) starts from no selection, so the match is all that's selected. Select sets TG's private _shiftSelecting as a Shift selection does (#361): without it TG treats the selection as a sticky mark that every plain move extends.
  • Cursor location history (#35) is CursorLocationHistory (Navigation/) — a pure, TG-free back/forward stack, unit-tested directly like MnemonicResolver. WorkbenchHost feeds it: within-file moves arrive via EditorGroup.CursorMoved (re-raised from TextView.UnwrappedCursorPositionChanged, which reports unwrapped model coords), file switches via ActiveTabChanged. The history's own heuristic decides what counts as a navigable jump vs. drift: a jump is a different file, a row delta ≥ threshold (default 10), or an explicit jump (Go-to-line passes explicitJump: true so even a short hop records). Drift (small same-file moves) just updates the current entry so a later jump's Back target is where the user actually was — typing and single-arrow steps never fill the stack. A jump truncates the forward/redo path (browser semantics). Driving the cursor on Back/Forward/Go-to-line sets _suppressHistory so those programmatic moves aren't re-recorded. No key interception is needed (the event is editor-scoped), so the explorer/settings/TreeView are untouched — the two concerns #35 flagged both dissolve.
  • Open dialog quick filter (#109): typing in OpenView (Ctrl+O) filters everything under the directory being browsed with JetBrains-style CamelHumps (CamelHumps, pure and TG-free; fdl → FileDirectoryListing). A query with a / matches path segments in order (nav/ov); otherwise only the name. The list comes from QuickOpenIndex.Scan — breadth-first, skipping WorkspaceSearch.ExcludedDirectories, capped at 10,000 entries — run once per directory on a background task when the first character is typed (synchronously when unhosted). Filter keys reach the view through a wrapper around the modal's scope, since bindings can't cover every printable key. Esc clears a non-empty filter before it cancels; Enter on a matched folder browses into it and clears the filter.
  • Go to symbol (gs, no default key, Editor scope) opens SymbolPickerView on the definitions in the active file: a TextField filter over a ListView, the shape of RevisionPickerView / PullRequestPickerView, filtered by the same CamelHumps as Open (SymbolList, pure — file order is kept, not sorted by score). Enter moves the cursor to the symbol's line and records it as an explicit jump, as Go to line does; Esc leaves the cursor alone. No file open and a file with no grammar (plain text, or a tab pinned to plaintext) are one status-bar line each; a file that has a grammar but no definitions opens the picker and says No symbols in this file in the AlertView at its foot once the scan is over. The hint bar is still a one-row Label, like its sibling pickers (#208).
  • The definitions come from SymbolScan (TuiCode.Syntax, TG-free, per-language tests in SymbolScanTests), which re-tokenizes the buffer with TokenizeLine — the scoped call the colour path doesn't use, which keeps TokenizeLine2. A token is a definition when the grammar names it one (entity.name.type.class|interface|enum|struct|trait, entity.name.variable.property|field|enum-member, variable.other.enummember) or its scope stack says it's being declared (meta.function.definition, meta.definition.function|method|property, meta.function, meta.method) and isn't inside a use (meta.block, meta.function-call, meta.function.call, meta.member.access, the parameter and return-type scopes). Scope matching is whole-segment, so meta.function matches meta.function.definition but not meta.function-call — Rust's calls and Python's print both turn on that distinction.
  • Where the stack is flat the grammar can't tell a declaration from a call at all: C# gives entity.name.function.cs to both DoWorkAsync's declaration and Console.WriteLine. The fallback is the line's first name that isn't a type, and only on a line that has already declared something — a storage.modifier, storage.type, keyword.type or entity.name.type ahead of it. The pitch's rule stops at the first clause; without the second, await DoWorkAsync(1); reads as a definition. Both clauses are needed: the second passes var y = Compute(x); (var declares), the first rejects it (y is the first name).
  • Each row reads as an outline rather than a bare name (#225): the name, then the kind and the 1-based line right-aligned in columns as wide as the widest of each, rendered by the pure SymbolList.Render. The name gives up whatever the two columns need and is truncated with an ellipsis, so they stay aligned at any width. The kind is the grammar's own word — SymbolKind splits entity.name.type.{class,interface,enum,struct,trait} into a member apiece rather than one Type.
  • The kind also gets an icon ahead of the name (nf-cod-symbol_*, SymbolIcons), Nerd Font only — no emoji reads as "interface", and the kind word carries the row without one. SymbolList.Render leaves the columns for it and SymbolListSource draws the glyph into them, after the row's indent so the icons step in with the nesting.
  • TypeScript gives a class field and an interface's property the same meta.field.declaration + variable.object.property, so only the type around them tells them apart: inside meta.interface it's a property, anywhere else a field (#226). Rust's struct fields are plain variable.other and Python's class attributes meta.attribute, neither of which says "declaration", so gs still lists only their types and functions.
  • Markdown is headings and nothing else (#226), read a line at a time rather than token by token: inline code or a link splits one heading's text over several tokens, and a setext heading (Title over =====) is scoped on its underline with the text on the line above. SymbolScan.AddHeading takes the text between the first and last entity.name.section token, which drops the #s at both ends and keeps ## C# whole, and the level from the heading.<n> / markup.heading.setext.<n> scope. A # inside a fenced code block is the embedded grammar's comment, which the scan already never names.
  • Nesting comes from the line's indent, not the scope stack (SymbolScan.DepthAt), except for headings, which pass their level to the same stack so ### sits under its ##. Only some grammars nest: a TypeScript method carries meta.class.ts, while C# gives a method just source.cs | entity.name.function.cs and nests nothing at all, so layout is the one signal every grammar shares. A stack of enclosing indents gives the depth, which makes the indent unit irrelevant, and a definition on the same line as the one before it — interface Thing { a: number } — sits inside it. A filter flattens the rows, since the containing type may not have survived it.
  • The scan runs in 15 ms slices from WorkbenchHost.OnIteration (SymbolPickerView.Advance), so a 3,000-line file fills the picker in over a few frames instead of blocking. A line that overruns TextMate's 100 ms limit is read again from the same state rather than accepted partial, up to SymbolScan.MaxRetries — a cold grammar otherwise drops the symbols on its first lines. The result is EditorTab.ScanSymbols(), cached against the tab's edit count and grammar, so a second gs without an edit opens on a finished list.
  • The status bar's Ln X, Col Y (right-aligned, #120) is polled on IApplication.Iteration (Workbench.ShowCursorPosition), not driven by CursorMoved: TG 2.1.0 raises UnwrappedCursorPositionChanged from its key/mouse paths only, so programmatic moves (find, Go-to-line, multi-caret, line moves) go unreported. StatusBarPart.SetPosition no-ops when unchanged, so polling costs a tuple compare. Col counts the model's grapheme cells, so a tab is one column.
  • macOS gotcha: by default Mission Control's "Move left/right a space" eats Ctrl+Left/Ctrl+Right before iTerm2 sees them. Disable in System Settings → Keyboard → Keyboard Shortcuts → Mission Control.

Focus (#227, #228)

  • FocusService (Workbench/Focus/) is the single source of truth for the focused region — Editor, Diff, Explorer, Find, FindBar, Review or Tabs. FindBar is the find bar over the active file and Find the sidebar's pane; both read Find in the status bar, and because the bar sits inside the active tab, the Editor and Tabs ownership tests have to exclude it. WorkbenchHost.FocusedScope reads it (FocusService.ScopeOf) instead of polling the views, and the status bar's first word and the focused pane's border both come from its RegionChanged.
  • Nothing moves focus but the service. Every workbench-level move goes through WorkbenchHost.MoveFocus, which says so in the status bar (Nothing to focus in <region>) when the move can't land, rather than going quiet. A view focusing its own parts — the explorer tree, a tab's text view, the review pane's Overview button — is the region's own business; what may not happen is a view deciding which region has the keys. Workbench.OpenFile used to focus the tab it opened; it raises FileOpened instead and the host makes the move.
From Key or event To
Anywhere Esc Editor, or Diff when the active tab is a diff
Editor Ctrl+Esc / ft (FocusEditorTabStrip) Tabs
Tabs Enter, Down Editor
Anywhere fs / ts / a sidebar tab's shortcut the sidebar's active tab: Explorer, Find or Review
Anywhere fr (FocusReview) Review
Anywhere Ctrl+F / Ctrl+H (ff / rf) FindBar, or Find with no tab open; unchanged on a document, and for Ctrl+H on a diff
Review Enter on a file Diff
Anywhere opening a file, a diff, a document or a history jump Editor or Diff
Any region opening a modal unchanged — no region owns a modal
A modal Esc, or the dialog finishing the region it was opened from (FocusCallingRegion); the editor if that region has gone since
Explorer / Find / Review hiding the sidebar Editor or Diff
Any region a review refresh, a late PR header, a save, showing the sidebar unchanged (SettleFocusAfterRedraw)
  • A redraw must not take the keys. ReviewView.Show moves no focus at all; it raises Refreshed, and the host puts the keys back where the record says they were. A region that still holds them keeps them — except the bare review pane, whose file list a rebuild takes them off and can't give back. The same rule covers the PR header arriving from gh seconds after you've moved to the diff (#196).
  • Clear a stale HasFocus before taking the keyboard. TG 2.1.0's SetFocus is a no-op on a view that believes it still has focus, so the diff — which keeps the flag after the sidebar takes the keys — could never be focused again: the reported "no way back without restarting" (#197). WorkbenchHost.Take drops the flag when the keyboard is demonstrably elsewhere, then moves.
  • It's TG-free and unit-tested directly, like MnemonicResolver: the host registers each region with a move and an ownership test, and supplies the focused view.
  • Reconcile against the focused view, not HasFocus. TG leaves HasFocus set on a view focus has moved on from, so FocusService.Reconcile re-reads the region from what TG says is focused — on every Iteration and before each key is dispatched, since a mouse click lands between iterations. Focus records optimistically; a move that didn't land shows up as Unreachable and is corrected by the next reconcile.
  • Navigation.GetFocused() returns the view the navigation system last focused, which can be an ancestor of the one holding the keyboard (a focused SidebarPart over the explorer inside it). Walk down with View.MostFocused — DiffTab.IsFocused gets away without it only because a DiffTab is itself focusable.
  • Tabs is reached one way, the ft command (Ctrl+Esc), where the active editor tab keeps TG's focus underneath the strip. It owns the whole editor pane, which is what keeps cycling tabs from reading as a move into the editor body or a diff, and its CommandScope is Editor — the scope those keys already fired in.
  • PaneTabs claims Up/Down/Left/Right and does nothing with them (#254, #255), so an arrow the focused view can't use reaches nothing instead of silently costing you what you were reading. Both of our Tabs are one: EditorGroup, where Up or Left at the very start of a buffer — TextView deliberately gives those up — used to take the file off the screen, and the sidebar's, where an arrow the pane didn't use switched the pane out from under you. Two layers want those keys: TG's Tabs binds all four to tab switching and lists them in CommandsToBubbleUp, and under that the application binds CursorUp/CursorLeft to PreviousTabStop and CursorDown/CursorRight to NextTabStop, which moves focus to another tab and has Tabs.OnFocusedChanged follow it. Replacing the command implementations covers both, since a key consumed in the view hierarchy never reaches the application's bindings. Removing the bindings instead wouldn't: the key would fall through to the application's. Nothing is filed upstream, so this note is the only record — a TG upgrade that restores the default brings all of it back.
  • The sidebar's case is also what made the readout lie: the pane changed but FocusService kept the region, so the bar said Review over a showing Explorer. Nothing reconciles that away, because no move happened that a reconcile could see — the region was simply no longer the pane on screen. With the arrows held there is no such move left. The panes that escaped are the ones whose focused view uses no arrow: the Find query box, the PR overview link, and the review pane with nothing to review (its file list is hidden, so the pane itself holds the keys). A tree consumes all four, which is why the explorer and a populated review list never showed it.
  • TG draws each tab's header in that tab's border, so WorkbenchHost.OnTabStrip tells a header holding the keyboard from the tab's content. Only an editor tab counts: TG parks on a diff's header as a matter of course, and the diff's keys still go to the diff.
  • The border highlight is a GettingAttributeForRole hook on the pane's border view (FocusBorder), swapping Normal→Focus as the attribute is resolved. TG draws border lines in Normal whatever has focus; overriding the scheme instead would have to be reapplied on every theme change.

Versioning (#214)

  • Every assembly's version comes from MinVer (root Directory.Build.props), which reads the latest v* tag: on the tagged commit that's 0.0.4, three commits past it 0.0.5-alpha.0.3. MinVerTagPrefix is v, since MinVer defaults to unprefixed tags. About (tui) shows it through WorkbenchHost.AppVersion, so a build says which worktree it came from rather than the SDK's 1.0.0.
  • MinVer needs the tags, so every job that builds the app checks out with fetch-depth: 0; without them it falls back to 0.0.0-alpha.0.
  • MinVer overrides a plain -p:Version=, so the release publish passes the tag's version as -p:MinVerVersionOverride= instead. -p:Version= there would quietly ship an -alpha version in a release archive.

Release workflow

  • .github/workflows/release.yml runs from the Actions tab only (workflow_dispatch, with a bump choice) — see CONTRIBUTING.md § Cutting a release. It calls a-team's reusable release-version.yml for the next version, matrix-builds AOT single-file binaries on native runners for each RID (Apple Silicon only on macOS — Intel Macs are EOL), archives them (.tar.gz on Unix, .zip on Windows) with a .sha256 sidecar, then calls release-publish.yml, which tags the commit, publishes the release, opens the Homebrew tap PR and commits the Scoop manifest.
  • Both shared workflows are pinned to a released a-team tag (@v0.1.3), never a branch. Anything missing from them gets fixed on mentaldesk/a-team and re-pinned here, not forked into this repo.
  • A called workflow doesn't inherit the caller's permissions, so the version job declares contents: read + pull-requests: read and publish declares contents: write.
  • publish runs only from main, in the release environment, which only main can deploy to. The packages token is that environment's PACKAGES_TOKEN secret, so a workflow on any other branch can't read it. publish passes secrets: inherit because that's the only way an environment's secrets reach a called workflow (actions/runner#4453).
  • Shared publish creates the tag itself (gh release create --target $GITHUB_SHA), so the caller must never tag, and the build matrix is fail-fast: true: a platform that fails leaves no tag behind to clean up. concurrency: release stops two dispatches racing for the same version.
  • macOS signing + notarization auto-enables when these secrets exist; without them the macOS tarballs ship unsigned (Gatekeeper quarantines on download):
    • APPLE_CERT_BASE64 — Developer ID Application .p12, base64-encoded.
    • APPLE_CERT_PASSWORD — password for the .p12.
    • APPLE_SIGNING_IDENTITY — identity string, e.g. Developer ID Application: Name (TEAMID).
    • APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD — for notarytool submit. App-specific password from appleid.apple.com.
  • Archives also carry THIRD-PARTY-NOTICES.md and DOTNET-THIRD-PARTY-NOTICES.TXT. Components compiled into the binary need their notices shipped with it (Oniguruma's BSD-2-Clause says so explicitly), and the release has no other documentation. TuiCode.csproj's PublishThirdPartyNotices target copies both into the publish folder: ours from the repo root, the runtime's from the ILCompiler runtime pack ($(RuntimePackagePath)), so it matches the .NET version compiled in. CI's aot job checks they're there.
  • The Libraries section of THIRD-PARTY-NOTICES.md is maintained by hand: when a package that ends up in the binary is added (check src/TuiCode/obj/project.assets.json for runtime assets), add its LICENSE text. The Grammars section between the markers is generated (see Syntax highlighting); don't edit it.
  • A bare Mach-O can't carry a stapled notarization ticket, so we notarize the tarball; users pick up the ticket via the Gatekeeper cache on first launch.
  • Linux/Windows arm64 use the public ubuntu-22.04-arm / windows-11-arm runners — native, no cross-compile.
  • packaging/tuicode.rb is the tap's formula and packaging/tuicode.json the bucket's manifest, with the version and SHA256s replaced by {{version}} and {{sha_<rid>}}, which shared publish fills from the archives it downloaded. It fails the job on a placeholder no archive matched, and PackagingTemplateTests checks every placeholder against the build matrix so a typo surfaces in CI instead of mid-release. The formula has no version line — brew audit --strict rejects one that duplicates the URL.
  • The Scoop manifest is committed straight to the bucket's default branch — it has no CI to gate a PR — so PackagingTemplateTests also checks it parses as JSON once rendered. PACKAGES_TOKEN needs Contents write on mentaldesk/scoop-bucket as well as the tap; the shared workflow only checks the token is present, so one that can't reach the bucket fails actions/checkout and reddens the release.

Linux compatibility (#414)

  • The Linux legs build on ubuntu-22.04 / ubuntu-22.04-arm, not ubuntu-latest: a NativeAOT binary needs the build host's glibc or newer, and 2.35 is the README's floor (Debian 12, Ubuntu 22.04). CI's aot job fails if the binary references a GLIBC_ symbol newer than 2.35, so a runner bump can't silently raise it.
  • Linux RIDs publish with InvariantGlobalization (TuiCode.csproj), so the binary never loads libicu, which minimal images lack. Counts and sizes formatted with CurrentCulture therefore use invariant (en-US) separators on Linux whatever the locale; macOS and Windows still follow it. CI checks only Linux RIDs pick it up, and runs --smoke-syntax and --smoke in debian:12, ubuntu:22.04 and fedora:latest containers with no libicu, on x64 and arm64.
  • Invariant mode also makes culture-aware comparisons ordinal and new CultureInfo("xx") throw, so don't depend on either.

Conventions

  • Work in a dedicated worktree, never on main or the primary checkout directly — git worktree add ../TuiCode-<slug> -b <branch> origin/main, one branch per change. This is mandatory for agents: it's what lets multiple sessions/agents work the repo concurrently without colliding. (Human contributors are free to use their own workflow.)
  • Branches: milestone-N-<slug> (features), chore/<slug> (cleanup), fix/<slug> (bugs).
  • Commit subject: short imperative; body explains why.
  • Comment only for non-obvious WHY (hidden constraint, TG quirk, workaround). Codebase runs comment-light.
  • No abstractions ahead of need.
  • Don't add settings without a picker UI to drive them.