Project-specific gotchas not obvious from the source. CLAUDE.md symlinks to this file.
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.mdonly 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 inAGENTS.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.
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 macOSsrc/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 sidebarSearchView.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, plusPaneTabs, the one view type shared by two features. Features depend only on this.tests/TuiCode.Tests/— single test assembly.
- xUnit v3 on Microsoft.Testing.Platform. Don't switch to v2 +
Microsoft.NET.Test.Sdk— pulls inMicrosoft.TestPlatform.CoreUtilities15.x, which TG'sConfigurationManagerchokes on at startup. - Test names:
Method_describes_what_should_happen(snake_case after the method). - File-touching tests:
MockFileSystemfromSystem.IO.Abstractions.TestingHelpers. No temp dirs. - Never assert a
MockFileSystempath as a literal. On Windows the mock resolves/workagainst the current drive, soFullNameisC:\workand a hard-coded"/work"fails there and only there — aHostSteps.Runstep waiting on one just reportsRunAsync timed out. Put the expected path throughfs.Path.GetFullPath(seeStartupArgumentsTests.Full). - UI / focus / key bugs: drive via TG input injection (
host.App.InjectKeyfrom theIterationevent). SeeWorkbenchHostTests.CtrlQ_quits_the_workbenchand TG's testing docs. Faster than asking a human to retry manual steps. - Any test that boots a TG
Application(news up aWorkbenchHost, renders a View) or mutatesThemeManager/ConfigurationManagermust derive fromStaticConfigurationTest(issue #77). Those TG statics are process-global; under xUnit's default parallelism a theme mutation in one test makes TG's render path throwKeyNotFoundExceptionin another. The base joins the serialisedStaticConfigurationcollection 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
WorkbenchHostmust also passdriverName: DriverRegistry.Names.ANSI(the headless, deterministic CI driver). With the default (null) TG auto-selects the platform driver; on a headless Windows runner that'sWindowsDriver, which blocks on console input and hangs the run forever —RunAsyncdoesn't observe the cancellation token while blocked. Linux auto-selects ANSI already, which is why this only bit the Windows CI leg. Production passesnullso a real terminal still gets the best driver.
- Namespaces split:
Terminal.Gui.App,.Views,.ViewBase,.Drawing,.Input.global usingper assembly. - Static
Applicationis[Obsolete]. UseApplication.Create(timeProvider).Init(...).Run(runnable)againstIApplication.WorkbenchHostowns it. Tabs.Add(view); tab title = child'sTitle; active =Tabs.Value.- Setting a tab's
Titledoesn't redraw its header. TG copies the title into the header only during layout, andTabsplaces headers using each header's cached width (ITitleView.MeasuredTabLength) before re-measuring it. So after changing a title, clear that cached width and callSetNeedsLayout()(seeEditorTab.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
TabsswitchesValueto whichever tab takes focus, which isn't always the active one. So close a modal before making a tab active, not after (seeOpenRevisionPicker, #191). TextView.IsDirtyhas no setter.EditorTabtracks_dirtyviaTextView.ContentsChanged(subscribed after the initialText =so load doesn't dirty it).ContentsChangedisn't a reliable edit signal in TG 2.1.0. The kill commands (Ctrl+K,Ctrl+Delete, and mid-lineCtrl+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. SoEditorTextView.OnKeyDownruns every key bound to an editing command (EditCommands), andOnKeyDownNotHandledruns typed text, with TG's own raises held, then raises once if the undo recorder found a change (see Multiple cursors and undo).TextView.OnContentsChangedthrows awayTextModel's max-line-width cache on every edit (still true in TG 2.5.0), so the nextUpdateContentSizemeasures every line: ~225 ms per typed character at 50,000 lines.EditorTextView.OnContentsChangedworks 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, sinceUnsafeAccessorTypecan't return arefto an inaccessible type. If a TG upgrade renames them, every edit throws andEditorTextViewContentWidthTestsfails. 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)crashesTextView.PopoverMenuinit. To stop Esc-as-Quit, reassign Quit toCtrl+Q— seeWorkbenchHost.NeutralizeBuiltinQuitKey.View.SetFocus()returns false when any ancestor hasCanFocus = false. SetCanFocus = trueon containerViews that should host focusable children.ConfigurationManagerdeserializes via source-generatedJsonTypeInfo— only knows the types its built-in scopes use. Records, arrays, evenstring[]silently fail to load. Stick to primitives or persist to a dedicated file.Terminal.Gui.Drawing.Attributecollides withSystem.Attribute; fully qualify when constructing.
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.
LogViewis the one custom view:TextViewcan'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.Errordraws it in the theme'sErrorscheme,InfoinAccent.sr,gsandccuse it —PathPromptView,RevisionPickerViewandPullRequestPickerViewstill have the one-rowLabel(#208).InputView(Workbench/Controls/) is the guide's bordered multi-line field, used bysrandcc. 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 isInputView.Framerows and columns taller for the box.SubmitReviewView.Hintis the guide's clickable hint: aButtonwithNoDecorations,NoPadding, no shadow and no hotkey of its own.
- Release builds are Native AOT (
PublishAot=trueonsrc/TuiCode).dotnet publish -c Release -r <rid>emits a single native binary;dotnet build/dotnet runstill JIT. - All
src/projects setIsAotCompatible=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 genericJsonArray.Add<T>(T)overload is not. When appending aJsonObject/JsonArray, cast toJsonNodeto pick the right overload (seeDefaultSettingsService.SaveKeybindings).dotnet testruns JIT, so AOT-only failures (missing metadata, trim-stripped paths) won't surface there. CI'saotjob 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 --smokeunder a pty — boots throughApplication.Init, renders one iteration, exits 0. Add anything reflection-heavy with those smokes in mind; an AOT-compatible test framework is tracked separately.
IApplication.Keyboard.KeyDownfires before view dispatch. Single subscription:WorkbenchHost.OnAppKeyDown. SetKey.Handled = trueto consume.- All keybindings go through
IKeybindingService; never wireKeyDownon individual views. To add a binding:- Constant in
TuiCode.Abstractions.CommandIds. - Register handler in
WorkbenchHost.RegisterDefaultCommandsvia the labelledRegister(id, label, handler, scope)overload. - Bind in
WorkbenchHost.BindDefaults(called byApplyKeybindings). - Give it a home in the menu bar: its place in
CommandMenu.Layout.MenuBarHostTestsfails for a command with a mnemonic and no menu item, unless it's inCommandMenu.Unlisted.
- Constant in
- Command scopes (#132). Every command has a fixed
CommandScopefrom registration:Global(the default),Editor,Explorer,Find,DifforReview. 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;Handletries 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 withisEnabledthat says no is skipped, so its key falls through to Global: explorerEsccancels only a pending cut and editorEsconly clears a selection or extra cursors, otherwise it focuses the editor. The document-changing commands areEditor(#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 stayGlobal— they act on the tab as a whole and are useful from the file tree.ScopedKeybindingsHostTestspins 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 whateverisEnabledsays no to (#284):ActionViewfiltersICommandService.Registeredon theCommandScopeWorkbenchHost.OpenActionscaptures — captured there because opening the palette pushes a modal input scope over the region it came from — and onIsEnabled, 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+Hwith no tab open the Find pane, andEscsays "Nothing to focus in Editor". Settings › Keyboard Shortcuts stays the unfiltered reference. Each palette row ends with the command's mnemonic fromCommandMnemonics(#341), right-aligned so the column lines up, underCommand/Binding/Mnemonicheadings (ActionRow.Header, laid out like the rows and drawn in the palette's colours inverted, below aFilter:label);ActionRow.Displaylays 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, soslists thes…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 andisEnabledalike. - 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'sAlt+CursorDown, #194) and everything else asKey.ToString(). It's display-only and never parsed back, so identity staysKeyChord.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.Bindingsemits the lowercased form. Casefold in UI if needed. - Chord wins over view bindings:
Ctrl+WshadowsTextView.Cut. Use the unshadowed alternative (Ctrl+X). - Input scopes are a stack (
IInputScopeStack). Workbench scope is bottom, never popped. Modals push their ownKeybindingServiceon open / pop on close — workbench shortcuts don't fire while a modal is up. New modal: instantiateKeybindingService, 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 evenCtrl+Qwon't quit, so a host test that opens such a modal must Esc it shut (or let it auto-execute and close) before injectingCtrl+Q, elseRunAsynctimes 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 nativewindowsdriver passes that control char straight through, so the event reaches us asCtrlMask | 0x0A(0x4000000A) — which never matches aCtrl+Enterbinding (CtrlMask | Enter=0x4000000D).WorkbenchHost.NormalizeWindowsCtrlEnterrewrites it back to Ctrl+Enter for binding lookup (gated onIEnvironment.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'sCtrl+Enter: Save(_scopeKeybindings.Bind("Ctrl+Enter", CommandIds.SettingsSave)), which was silently dead on Windows before this. The diagnostics overlay still shows the raw0x4000000Aso the underlying arrival stays visible. This is a sibling of theCtrl+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 whatCtrl+_and oftenCtrl+7send), and theansianddotnetdrivers decode it asCtrl+7; thewindowsdriver reads the key (VK_OEM_2) and already getsCtrl+/.WorkbenchHost.NormalizeLegacyCtrlSlashlooksCtrl+7up asCtrl+/, after the Ctrl+Enter rewrite and before the scope stack, so bindings, the palette and Settings all still sayCtrl+/. It stands aside when kitty flags are negotiated (Driver.KittyKeyboardCapabilities), whereCtrl+7is real, and when the user's own overrides bind a chord starting withCtrl+7, so a deliberate binding wins. The defaultCtrl+7(Focus editor tab 7) doesn't count: in those terminals it was already indistinguishable fromCtrl+/.LegacyCtrlSlashDecodingTestspins each driver's decoding.
- A leader key (
Ctrl+Spaceby default, rebindable like any shortcut →CommandIds.ShowMnemonics) opensMnemonicView: 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 toCommandIds. Commands with no entry (ShowActions,ShowMnemonicsitself) 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 (cfclose-file reservesco/ca/cs;ts/tg/tctoggle;nf/nc/ntandpf/pc/ptnew/next and previous;fs/fe/ft/fr/f1…f9focus;gl/gs/gp/gngo to, andgbgit blame;cgchange grammar andcccreate comment, the twocs that aren't close;ff/fgandrf/rgfind/replace in the active file vs. globally — a trailingfmeans the active file, as insf/cf). A mnemonic is the command label's initials —fgFind globally,ntNext tab,rcRevert 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-ffamily,q/?,tuiandf1…f9.mu/mdmove anddu/ddduplicate lines,il/olindent and outdent them, andtlctoggles their line comment;aa/abadd a cursor above/below.q/?are the only single-key ones.tui(About, #107),opr(#184),or(#357),pro(#185),gs(#137) andcc(#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) keepstshonest 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).
ResolveExactfires 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 isMnemonicScopeHostTests.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 rejectionOnKeyalways 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(Matchingfor the hint list,ResolveExactfor the fire-now decision) so it's unit-tested directly.MnemonicViewis a thin capture-scope shell over it — it reads keys viaKeyCaptureScoperather than a focused TextField precisely so a command that disposes the view executes on the app key-dispatch path, not inside a TextChanged callback.
CommandMenu(Workbench/Menus/) fills TG'sMenuBaracross the top of the workbench fromCommandMenu.Layout: eight menus, every command with a mnemonic in exactly one, plus Show all commands and Show mnemonics. The exceptions are the focus moves inCommandMenu.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, throughRunLaunchedlike the palette. Its key column isKeyView.Text, set from the live bindings byApplyKeybindings, notMenuItem.Key: that holds oneKey, not a chord, and the workbench already dispatches the key, soBindKeyToApplicationis off.F10isShowMenu, 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 typedfwherever the keys are. Esc is re-added to each popover's Quit, whichNeutralizeBuiltinQuitKeytook 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
CommandMenuItemstaysEnabled, since TG's arrows skip a disabled view, and instead draws in the scheme'sDisabledcolour 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 someisEnabledchecks 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.Closedfires 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.Invokeruns 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).
HelpViewis 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 liveShowHelpbinding (WorkbenchHost.ApplyKeybindings), so a rebind updates it and an unbound help key hides it.
- 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.TextMatchcolumns are UTF-16 chars of the line; the TextView model counts grapheme cells, soEditorTabconverts at its boundary (Select/Replace/SetHighlights/SelectionOrigin). Always compute matches againstEditorTab.Lines, neverContent(which re-joins withEnvironment.NewLine). - In-file find (
Ctrl+F/Ctrl+H):FindController(Workbench/Find/) owns oneFindBarViewand docks it viaEditorTab.SetHeader, followingActiveTabChangedto whichever tab is active.EditorTab.Disposedetaches the header first so closing a tab doesn't dispose the shared bar. All-match highlighting isEditorTextView.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 throughDeleteCharLeft+InsertTextas one undo step; replace-all runs back to front. While there are matches the controller raisesHintChangedwith 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 viaStatusBarPart.SetHint, which overlays the normal message and reverts to it when cleared on close. - Find always lands in a search box (#229).
WorkbenchHost.OpenFinddecides 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, orCtrl+Hon 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.Openonly shows the bar; the host then moves the keys into it throughFocusService, so a staleHasFocuson the bar can't swallow the move (the region isFindBar, which readsFind). Which of the bar's two inputs has them is shown the way a focused pane is:FindBarViewdraws that field's label in the theme'sFocusattribute through aGettingAttributeForRolehook, 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 readingHasFocus, which TG leaves set on the field they've moved on from. - Find in a diff (#413).
FindControllerworks on aFindTarget: a file tab's buffer, or both sides of aDiffTab, docked withDiffTab.SetHeader. A diff'sTextMatch.Rowis two per row ofAlignedDiff, left side first, so the controller's row-then-column order is the diff's reading order andTextSearch's next/previous work unchanged;DiffMatchis 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 theHighlightrole over tints, changed words and syntax colours, the current one inActive, as the editor draws a selection. A diff gets no replace row, and switching to one hides it. Matches refresh onDiffTab.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.
LayeredScopegives 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-scopedEscorFocusEditorBody. 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 ordinaryFind-scoped commands. EditorGroupraisesActiveTabChanged(null)itself when the last tab goes: TG'sTabsnulls its value silently when the selected tab is removed and none remain.- Global find/replace (
Ctrl+Shift+F/Ctrl+Shift+H):SearchViewis the sidebar's Find tab — user-facing text says "find", matching thefg/rgcommands; "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 withApp.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 viaIFileSystemwith EOL/BOM preserved. Replace-all needs a secondCtrl+Enterto 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)..gitignoreisn't honoured yet. - Sidebar tabs.
SidebarPartis nowTabs(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 — onlytshides 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.fsfocuses whichever tab is active.Ctrl+Shift+Fstill takes the replace row off the Find pane, asCtrl+Fdoes on the bar. - Keys needing a capable terminal:
Shift+Enter(previous match) and theCtrl+Shift+letterchords (collapse ontoCtrl+letterin Terminal.app — thefg/rg/semnemonics always work).
- Delete (
Delete/df) is permanent for now — moving to the Trash is #128 — behind aConfirmViewwhose 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, soFocusInputre-applies the selection. Tabs follow viaEditorGroup.Relocate(re-keys_byPath, re-infers grammar unless pinned);CursorLocationHistory.Rebase/Forgetkeep history in step. Path arithmetic isFilePaths(Abstractions, ordinal) andEntryPaths(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. DeleteandF2areExplorer-scoped (Delete must still delete text in the editor).Ctrl+Dis 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, soActionView/MnemonicViewcapture whether the explorer had focus when they opened (RunLaunched).- Cut and paste (#127) is a second way to move:
Ctrl+XmarksFileExplorerView.PendingCut(drawn faint inDrawLine, before the icon handler so the icon dims too) andCtrl+Vmoves it through the sameWorkbench.MoveasF2, targeting the folderNewEntryTargetwould pick. Both areExplorer-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/Moveclear a cut at or under what they touch. Windows Terminal bindsCtrl+Vto its own paste, so thexf/pfmnemonics are the fallback. FileSystemTreeBuilderhands out freshIFileSystemInfos on every read and TG'sBranch.Refreshmatches children by reference, so a plainRefreshObjectcollapses every folder under the refreshed node.FileExplorerView.RefreshKeepingExpansionrecords expanded paths and re-expands them.- Refresh explorer (
re, Global, no default key, #332) isFileExplorerView.Refresh:RefreshKeepingExpansionfrom 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 inFileExplorerView.ExpandedFolders, capped at 64 (most recently expanded win), debounced 250 ms and flushed throughWorkbenchHost.ScheduleFlushintoFileExplorerView.Refresh(folders). TG'sTreeViewraises nothing on expand/collapse, so the view re-derivesExpandedFoldersafter its own refreshes and on every draw. An automatic refresh only scrolls if the selection was already in view. In host tests the Explorer andDiskWatchercan both watch a folder, so raise events throughFakeWatcherFactory.At.
EditorGutteris a sibling view left of theEditorTextViewinsideEditorTab, 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'sViewportChanged/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'sYtoo.- 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 atHEAD(#370), so a save doesn't clear what isn't committed; otherwise it's the lines at load, reset onSaveand reload.CommittedBaselines(Workbench/Git/) readsHEADin the background on open, save and reload (EditorTab.BaselineReset) withgit cat-file --filters, so CRLF and.gitattributescheckouts compare clean, and hands it toEditorTab.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 sinceHEAD, or git failing, it stays null and the save baseline stands, quietly.HEADmoving outside the editor — a commit, checkout, reset or pull in another pane — re-reads every open tab from that repo (#371):HeadWatcherwatches each repo's git dir forHEAD, and its common dir forpacked-refsand, recursively,refs/heads, whichGitRepository.Dirsfinds from the filesystem (a linked worktree's.gitfile names its own git dir, whosecommondirnames the shared one). OnlyHEAD,packed-refsand the branchHEADnames count, sogit statusrewriting the index reads nothing. Events are debounced 250 ms likeDiskWatcher'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, whichIsDirtydoesn't. The diff is lazy:EditorTab.OnEditedinvalidates it and the next draw recomputes, so a hidden gutter costs nothing. PastLineDiff.MaxEditsMyers 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 andAlignedDiffuse, still gives up outright. AlignedDiff(the model for the side-by-side diff tab, #61) laysLineDiff.Hunksout 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 thanMaxEditsaway 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), notEditorTextView.LineStrings(which builds a string for every line). TG edits a line'sList<Cell>in place, so the snapshot spots changed lines by reference plusList<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, soLineDiff's comparisons against the baseline hitstring.Equals' reference fast path. Don't useContentsChanged'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 theContentsetter calls it directly.TextView.Text =raisesContentsChangedas well in TG 2.1.0, so setting the whole buffer arrives there anyway — which is why a reload (#269) holdsEditorTab._loadingover it: taking up the file on disk isn't an edit and mustn't dirty the tab. - Only set the gutter's
Widthwhen it actually changes — TG'sWidthsetter schedules a full screen clear even for an equal value. - Visibility is
EditorGroup.GutterVisible(on by default, applied to open and future tabs), toggled bytg. 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.
- Compare to saved (
cts, no default key) opens a read-onlyDiffTabin the editor group: the file on disk on the left, the live buffer on the right, laid out byAlignedDiffwithDiffTab.MaxEdits(5,000) as the give-up limit.DiffTab.ReadLinessplits the file with TG's ownCell.StringToLinesOfCells, so an unedited buffer compares equal. - Diff tabs sit in the same
Tabsstrip as editor tabs, so cycling, focus-by-index and closing work fromTabCollection, not_byPath.EditorGroup.ActiveTabis null while one is active, so editor commands skip it; useFocusActiveto 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 asWheeledLeft/WheeledRightbut drops a real horizontal wheel. Tints come from the token theme'sdiffEditor.removedLineBackground/insertedLineBackground, which every bundled theme sets opaque (terminals can't blend VS Code's translucent ones). - Changed words (#369): on a
Modifiedrow,WordDiff.Changesmarks the words that differ withdiffEditor.removedTextBackground/insertedTextBackgroundover 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 withLineDiff.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.WordChangesis computed inRefresh, not per draw. Every bundled theme sets both keys; a theme without them getsDiffTab.DefaultRemovedText/DefaultInsertedText. - One vertical
ScrollBarfor both panes (#295), in the tab'sPaddingsoViewport.Widthshrinks by a column while it shows andSideWidthssplits what's left. It'sAuto, sized to the display rows (_rows, thread and draft rows included) overPageHeight, andSyncScrollBarkeeps it in step fromScrollToandBuildRows. Its slider round-trip is lossy, so a sync never feeds back intoScrollTo; 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 atVisibleContentSize, 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) reusesOpenView, 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 likects.DiffTab.LeftKey(its full path) tells apart diffs against same-named files;LeftLabelis 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
LineTokenCachefor the source tab's grammar (pinned or inferred, re-checked onRefresh), so the left side is lexed from its own lines. A frame lexes each side top-down through its last line in view withinDiffTab.SyntaxBudget, as the editor does. Tokens set the foreground and style over the row's tint (TokenPalette, shared withEditorTextView).WorkbenchHost.ApplyTokenThemeredraws 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 offAlignedDiff.BlockandEditorTab.ReplaceLines— oneEdit()recording, soCtrl+Zin the editor takes the whole revert back in one press. Nothing is written to disk: the tab just goes dirty andCtrl+Sis still what commits. The diff then refreshes in place and the rows the revert wrote are tinted in theFocusrole until the next change move, the next revert or the next refresh — no timer. A freshly opened diff sits above the first change (CurrentChangeis 0 there, which is whatN changesin the status bar means), so revert takes that first change rather than refusing — otherwise the very firstCtrl+Rafterctslooks dead (#250). It runs in every diff with a live buffer on the right, socts,ctr,ctoand the Review tab's branch and PR diffs all revert the same way and the message names that diff's ownLeftLabel(#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 oneEditorTab.ReplaceLines, so oneCtrl+Ztakes every block back together. It's the only part of revert that confirms first — unlikercit throws away work that isn't on screen — through the sameConfirmViewas Delete file, Cancel focused. Nothing is written to disk either. It runs whereverrcreverts; a deleted file's diff has no buffer to write into, so there it points atrcinstead. - 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) areDiff-scoped commands, so while a diff tab has focusAlt+Up/Alt+Downwin over the global move-lines bindings (notShift+F7: iTerm2 swallows it). Next/previous stop at the last/first change and put the change two rows below the top.Enterfocuses the source tab onAlignedDiff.BufferLineof the current row, recorded as an explicit jump, and then reveals the whole change block there (#287):DiffTab.CurrentChangeLinesis 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, fromAlignedDiff.AddedLines/RemovedLines, so a modified row counts on both sides and a side with none is left out, #395),Change 2 of 5(N changesabove the first) and the keys (the page pair asShift+←/→), read from the live bindings inApplyKeybindings. TG leaves focus in the diff tab's header and doesn't clearHasFocuswhen focus moves on, soDiffTab.IsFocusedasksNavigation.GetFocused()instead. That header is also why sideways scrolling (Left/Right,Shift+Left/Shift+Right) is a set ofDiff-scoped commands rather than the view's own key bindings (#193):Left/Rightfrom the header used to be claimed for tab cycling before they reached the diff tab, whileUp/Down,Home/End,PgUp/PgDnandEnterbubble past it unclaimed. Scoped commands run before TG sees the key, so they are unaffected by #254.DiffTabkeeps its bindings for the wheel and for view-level tests.
Alt+Up/Alt+Downmove, andAlt+Shift+Up/Alt+Shift+Downduplicate, 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+Downadd a cursor above/below every cursor, keeping the column across shorter lines.Alt+Clickadds or removes a cursor, and a plain click orEscgoes back to one.EscisClearSelection(#362),Editor-scoped and enabled only with extra cursors or a selection: one press drops both, leaving the primary cursor where it is.RemoveSecondaryCursorskeeps 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 inEditorTextView._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 inCaretCommandsrun 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.IsSupportedis 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,ClipboardToolscopies again through the platform's own program (pbcopy,wl-copy/xclip,clip.exe) and reads that back too (#319).pbcopyandpbpasterun withLC_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 swapEditorTextView.ClipboardFallbackfor one over a fakeIProcessRunner, so none spawns a real process. TG 2.1.0'sTextView.OnKeyDownreadsSelectedLength, which rebuilds the selection by repeated string concatenation, so every key after select-all on a large file took minutes;EditorTextView.OnKeyDowncalls 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+),TerminalCursorshas the terminal draw the focused editor's secondary carets in the main cursor's shape.WorkbenchHostqueries support at startup (QueueAnsiRequest; a terminal that doesn't answer is dropped after a second). After eachLayoutAndDrawComplete(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,EditorTextViewhides 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 toEditorTab.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 oneEditGroupwith 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, andUndo/Redo(the commands and the methods, vianew) are ours. Edits reach the recorder throughEdit(several carets, line moves),EditAtPrimary(a key, orEditorTab.Replace), orOnContentsChangedas 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 pureOccurrencesclass 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.
InvokeEditCommandsdeletes the selection instead, as VS Code does. - Column select (#114):
Ctrl+T C(mnemonictc) togglesEditorGroup.ColumnSelect, applied to open and future tabs likeGutterVisible, and flagged in the status bar (StatusBarPart.SetMode) because nothing else shows the mode is on. While it's on, the extend commands (ColumnCommandsinEditorTextView.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_columnTrackrestores 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.
EditorTextViewoverridesOnDrawingContentwith a copy of TG 2.1.0'sTextViewdraw loop that stops at the viewport bottom. Upstream walks every row fromViewport.Yto 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 raisesDrawNormalColorper cell, so nothing should subscribe to that event. Resolve attributes once per frame inOnDrawingContent, not per cell. EditorTextViewDrawTestsasserts the copy paints exactly whatTextViewdoes (tabs, wide glyphs, horizontal/vertical scroll, selection, overwrite cursor, read-only). On a TG upgrade, re-diffTextView.Drawing.csagainst the copy; drop it once upstream bounds the loop.EditorDrawBenchmarkTestsisExplicit(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 withViewportSettingsFlags.HasScrollBarson the instanceEditorTabbuilds, notTextView.ScrollBars. Both areAutoand sit in the text view'sPadding: 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, soEditorTab.OnSubViewsLaidOutlays 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, soScrollBarResize.Holdrefuses any value a bar proposes while its own frame is changing, unless it's where the text view already is (#324).ThinScrollBarredraws 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).
Ctrl+T W(tww,Editorscope) flipsEditorTab.WordWrapfor that tab only. A tab opens wrapped when Settings › Editor's Wrap long lines (EditorSettings.WordWrap, #380) is on:EditorGroup.Trackcopies it once, so saving the setting leaves open tabs alone. Wrap by language (#381) overrides it per grammar id: the user'sEditorSettings.WrapByLanguagefirst, thenDefaultWrapByLanguage(Markdown and Plain Text on; a tab with no grammar isSyntaxHighlighter.PlainText), else the global setting (EditorSettings.WrapsLanguage). A tab's grammar at open decides it, socgdoesn't rewrap. The pane's rows are the TG-freeWrapLanguageRows; they persist underWrapByLanguageinTuiCode.settings.json. The status bar showsWrapafter the grammar while the active tab wraps. TG'sTextView.WordWrapstays off: it swaps the model for a pre-wrapped one, so every row-keyed feature would read wrapped rows. InsteadEditorTextViewdraws 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.WrapMapholds every line's row starts and the screen row each line starts on, re-measuring only lines whoseLineSnapshotstring 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.Yis a screen row and the content size is (viewport width, screen rows), set throughOnContentSizeChanging, 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 theViewportoverride ignores those writes and the next draw scrolls the caret's row into view instead; only the vertical bar's writes (flagged from itsValueChanging) and our ownbase.Viewportones land. Resizing keeps the top line in place. - TG's
PositionCursorand 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,LoadandMoveCursorask for a draw so a move TG didn't redraw for still gets its cursor placed. ↑/↓andPageUp/PageDown(and theirShiftforms) move by screen row (#379):EditorTextView.MoveByRowshandles them before TG, keeping the screen column the caret started from while it moves only that way.Home/Endand 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+Clickgo 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):_boxholds (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+Endtake the box to the row's edges, not the line's. Adding a cursor above/below is by file line. The status bar showsColumn selectbeforeWrapwhen both are on.
- Engine is TextMateSharp (VS Code grammars;
TokenizeLinecarries 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.libonigwraplinks it into the AOT binary so the release stays one file; without it publish dropslibonigwrapbeside the binary and the release archive (binary only) would ship broken. On macOS the static library prints ~14ld: 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 itslanguage-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'sRegistryOptionsat runtime — referencing it roots the whole uncompressed assembly into the binary.GrammarBundleis ourIRegistryOptionsover the zip. - Bumping TextMateSharp: update the version in
TuiCode.Syntax.csproj,TuiCode.Tests.csprojand the#:packageline ofscripts/update-grammar-bundle.cs, thenGITHUB_TOKEN=$(gh auth token) dotnet run scripts/update-grammar-bundle.cs. The zip is reproducible, andGrammarBundleTests.Bundle_matches_the_TextMateSharp_Grammars_packagefails if the zip and the package drift. The same run regenerates the grammar section ofTHIRD-PARTY-NOTICES.mdfrom each package'scgmanifest.json, fetching upstream LICENSE files (and Apache NOTICE files) from GitHub at the pinned commit — hence the token. Review anyWARNINGit prints: a source with no license file upstream gets a placeholder note (currently the Razor grammar, whose repo is gone). GrammarBundle.LanguageForFilematches exact file names (Dockerfile) first, then the longest known extension (bundle.js.mapis JSON via.js.map).filenamePatternsglobs and injection grammars aren't supported (TextMateSharp's ownRegistryOptionsdoesn't do injections either).- Wiring: one
SyntaxHighlightersingleton (one TextMateRegistry, so each grammar compiles once) flows DI →EditorPart→EditorGroup→EditorTab, which gives itsEditorTextViewaLineTokenCachefor 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, withinEditorTextView.SyntaxBudget(15 ms), andApp.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, andTokenizeThroughreturns false so the next draw re-lexes it, up toLineTokenCache.MaxRetriestimes. TextMateSharp'sStoppedEarlyis internal, so an overrun is detected by timing the call.- The cache is reconciled with
Snapshoton every draw, not onContentsChanged(unreliable, see above). That's cheap because unchanged lines keep their string instance, so the prefix/suffix comparison hitsstring.Equals' reference fast path. - Colours are painted in
DrawRowfrom the row's tokens, never stored inCell.Attribute— TG's undo, paste,InheritsPreviousAttributeand WordWrap all rewrite cell attributes. Token offsets are UTF-16 chars, so the row walk accumulatesGrapheme.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:
WorkbenchHostpicks the token theme for the current UI theme (see Themes). A switch bumpsSyntaxHighlighter.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.Associationsholds the built-in ones from the grammar packages; the user's, inISettingsService.GrammarAssociations, win over them (SyntaxHighlighter.LanguageForFile). A value ofplaintext(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: apackage.jsonwhosecontributes.languages/contributes.grammarsdeclare 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 throughIFileSystemat 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.tmLanguageor YAML);injectTogrammars register but aren't injected, as with bundled ones. Anything unusable — nopackage.json, bad JSON or wrong types, a missing or non-JSON grammar — is skipped and described inGrammarBundle.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.CreateCacheandLineTokenCachecatch 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 opensGrammarPickerViewfor the row, Delete resets it to the default, and a filter that's a new pattern gets anAdd "…"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-freeGrammarAssociationRows. Edits apply on Save:WorkbenchHost.ApplyGrammarAssociationsupdates the highlighter and re-infers every open tab. cg(Change grammar) opens the same picker for the active tab;EditorTab.SetGrammarpins that tab so later association changes leave it alone (InferGrammarskips 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#, orPlain Text).Workbenchrefreshes both onActiveTabChangedandEditorGroup.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.
tuiopensAboutView. 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.Detectsends DA1 once at startup (WorkbenchHostctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced.SixelProbe.MeasureCellruns 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'sSixelSupportDetector: it asksCSI 16 tfirst and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins.MeasureCellsends iTerm2'sOSC 1337 ; ReportCellSizeandCSI 16 ttogether and takes the first answer.Detectalso parses tmux's DA1 reply (…;4c), which TG's check misses.- iTerm2 reports sizes in points everywhere, its
CSI 14 treply included. OnlyReportCellSizecarries the scale (2.0on Retina), soParseIterm2CellSizemultiplies 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
MeasureCelltoo 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.Fitrounds down to whole rows andCovertrims 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.Disposedequeues its sixel and setsClearScreenNextIteration. Otherwise the image stays on screen after close. - The artwork is
assets/about.png, baked into the embeddedAbout/about.rgb.z(raw RGB, zlib) bydotnet run scripts/update-about-image.cs. We have no image decoder at runtime and don't want one in the AOT binary.
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 raisesChangedwhen 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, andAuto, the default.Autobecomes Nerd Font or Emoji depending onTerminalFontDetection, 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 inNF/NFM/NFP. Any other terminal gets Emoji. Terminal-specific env markers (WEZTERM_PANE,KITTY_WINDOW_ID, …) identify the terminal when tmux has replacedTERM_PROGRAM.TERM_PROGRAM=vscodewins over markers leaked from the shell that launched VS Code. - Icons are drawn, never put in the display text. In a tree,
IconDrawing.Prependinserts the icon cells inDrawLine(InsertAtfor an icon that belongs elsewhere in the row), soAspectGetterstays the bare name and type-to-jump still matches. In aListView,IconListSourcedraws the icon, then hands the text to aListWrapper<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.ForChangeis the Review tab's change mark (#320):nf-cod-diff_added/_modified/_removed/_renamedwith Nerd Font, theA/M/D/Rletter with Emoji, both green/amber/red/blue, and null with icons off. Its colour is the token theme'sgitDecoration.<kind>ResourceForegroundwhere 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 inTHIRD-PARTY-NOTICES.md). It has about 700 exact file names and extensions, each with a dark and a light colour. To update it, bumpcommitinscripts/update-file-icons.cs, then runGITHUB_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.tstriesspec.ts, thents), all case-insensitive. Folder icons and the generic file icon are fixed inFileIcons, 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 inTuiCode.config.json: TG silently ignores that whole file, theme included, when it holds a key TG doesn't know.
-
IGitCli(GitCli) shells out togit; no LibGit2Sharp, which would ship a native lib per platform. Each call runs with a 5 s timeout, and a missinggit, a non-zero exit or a timeout comes back as a failedGitResultwith a status-bar-ready message, never an exception. Null values mean "not in a repo" / "not in that revision". -
Runs with
LC_ALL=Cso "not a git repository" can be recognised, and passes file paths as./<name>with-Cset 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. -
GitCliandGitHubClishare onlyCliProcess, the start / timeout / kill plumbing; each builds its ownProcessStartInfo(git passes-C, gh runs in the repo root). -
Tests against a real repo live in
GitCliTestsand skip whengitisn't on PATH. Anything above the wrapper should test against a fake. -
Compare to revision (
ctr, no default key) opensRevisionPickerViewonceGetRepoRootAsyncfinds a repo: aTextFieldfilter overHEAD, the refs and the file's last 200 commits (RevisionList, pure: names by CamelHumps, commits by hash prefix or subject).HEADis always the first row, soctrthen 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 (HEADin any case), else the first row, so typingHEADdoesn't pickfeature/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 (WhenDonemarshals back withApp.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,Editorscope, #330) opensBlameViewfor the cursor's line:git blame --line-porcelain -L n,n, parsed byGitCli.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 anInfoalert instead of commit details.no such pathfrom git means the file is untracked. Nothing to say (no repo, untracked, nogit, 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): aDiffTabbetween two revisions, titleda.cs 4a91c0e^ ↔ 4a91c0e, with noSourceand aRightLabel. Blame'sfilenameandpreviousheaders 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 noprevious) has an empty left side. Both sides are read withShowRepoFileAsyncbefore the tab opens. Revert, revert all and go to line are disabled there, so the hint bar only shows next/previous.Enteris bound on an uncommitted line too, as a no-op, so it doesn't press the focusedEsc closehint.
- The sidebar's Review tab (
ReviewView,Review/) lists what the current branch changes against the merge base ofHEADwith the default branch (IGitCli.GetDefaultBranchAsync:origin/HEAD's target, elsemain, elsemaster).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. Enteron 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 withShowRepoFileAsync(repo-relative, so a rename's old path works even when its folder is gone), and itsLeftKeyis<merge base>:<path>, so it never matches actrdiff 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 saysLast change in the review/First change in the review. What makes a diff part of the review isDiffTab.Review, aReviewSpot(the merge base, plus the file's place in the list) the host sets as it opens one —cts/cto/ctrdiffs leave it null and so still stop at their last change. It also gives the status barFile 3 of 7, shown beforeChange 1 of 4, and the Review tab's selection follows whichever file is showing. - A deleted file (#182) opens as a
DiffTabwith noSource: the base version on the left, every rowLeftOnlyand so tinted as removed, nothing on the right, and a title ending(deleted).EditorGroup.CompareDeletedbuilds 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 itsSettingsfrom the group, and go to line (Enter) only saysDeleted in this branch.DiffTab.Fileis 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.Restoreopens the left side (DiffTab.LeftLines) as an unsaved tab at that path throughEditorTab.Unsaved, dirty from the start with nothing on disk, soCtrl+Sis still what brings the file back and closing the tab leaves it deleted. The diff stays open, and a secondrcfocuses the tab it already opened. The hint bar readsrestorerather thanrevertthere (Workbench.DeletedDiffKeysHint), since that's what the key does. - The branch's PR (#183) comes from
IGitHubCli(GitHubCli), which shells out togh pr view --json …in the repo root, so TuiCode never handles a token and GitHub Enterprise works whereverghdoes. It's fetched after the git load, in its own background step, so the file list shows before the header fills in.BranchReview.WithPullRequestAsyncthen relists the files against the merge base with the PR's base — resolved asorigin/<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:@mepass whose numbers flag the●rows — gh resolves@me, so a review requested of one of your teams counts), so a missingghor an empty repo is one status-bar line rather than an empty dialog.PullRequestPickerViewis thenRevisionPickerView'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 thengh pr checkout <n>in it, so gh does the fetch and makes the branch — which is what forks need, and what letsgh pr viewthere find the PR again. The picker showsChecking out #<n>…meanwhile, refuses a second Enter, and stays open showing whatever failed. ThenWorkbench.OpenFolderswitches to the worktree (restoring its tabs, #13) andfrshows 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 andgh pr checkoutwould only fail. A PR from a fork is never matched that way —isCrossRepositoryleavesGitHubPullRequestSummary.HeadBranchnull, because a fork's branch name names nothing here andmainwould match the main checkout. - Both of those take minutes, not the usual seconds:
GitCliandGitHubClieach 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 --porcelainreports real paths, so the candidate goes throughrev-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/varsymlink 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 fromIGitCli.GetWorktreesAsync(git's order, bare and prunable entries left out, paths throughGetFullPath), less the oneGetRepoRootAsyncnames for the open folder, so a subfolder still hides its own worktree. It's registered ungated so that outside a repo it can sayNot 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 pureWorktreeList; Enter isWorkbench.OpenFolder. - Submit review (#187) (
sr, no default key) posts a review on the branch's PR: anOptionSelectorof the three verdicts (Comment first — approving shouldn't be one stray Enter away), a word-wrappedTextViewsummary, and the two hints. The hints are the buttons —NoDecorations/NoPaddingButtons with no hotkey of their own, soCtrl+Enter submit · Esc cancelreads 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. Likeopr, the PR is looked up before the dialog opens, so no PR and noghare 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 saysReview submitted on #<n>. The summary'sTabKeyAddsTabis off, so Tab leaves it for the hints instead of typing a tab; itsWordWrapis 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, anOverviewButtonand aLine, packed byReviewView.LayoutHeaderinto 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) — aLabelwider 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.GetAttributeForRoleisn't virtual in TG 2.1.0, so the hint's faint style comes from itsGettingAttributeForRoleevent, which only sticks withHandled = true. - The Overview tab (#185). The
Overviewbutton opens#<n> Overviewin the editor group: the PR's title, author and description, then each comment headed<author> · <date>under a---rule, built byPullRequestOverview(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:
Upfrom the top of the file list selects it andDowngoes 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
EditorTabandDiffTab:DocumentTab, a TGMarkdownview over text that isn't on disk, tracked byEditorGroupunder 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 andCtrl+C; there's no editing, noCtrl+Fand nothing in the folder's reopen list (#13), since there's no file to reopen. DocumentTabrenders throughScrolledMarkdown, aMarkdownwhoseOnDrawingContentreports the drawn region from the viewport, not the content origin. TG 2.1.0 reportsContentToScreen(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.ThreadsLoadedthen 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:
DiffTabkeeps 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,CurrentBufferLineandCurrentChangemap 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 witheditorCommentsWidget.rangeBackgroundfrom 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.ccon a row of a PR's diff drafts a comment on the head (right) side: a row with only a left side saysComment on a line on the right, and outside a PR's review diff it saysOpen a file from the Review tab to comment. The file has to be what GitHub has at the PR's head (GitHubPullRequest.HeadSha, fromgh pr view --json headRefOid) — the buffer is compared withgit show <head>:<path>before the dialog opens, since a comment on lines that have moved lands somewhere else on GitHub.CommentViewis anInputViewwith[ Add ]/[ Cancel ](and[ Delete ]whenEnterreopens 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. DraftCommentsholds 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 saysDraft review: <n> commentsat its foot and thesrdialog<n> draft comments will be posted with it.. Submitting posts them with the verdict in one call:gh pr reviewcan't carry line comments, so a review with drafts goes togh api --method POST repos/{owner}/{repo}/pulls/<n>/reviewswith the body on stdin (CliProcesswrites 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 titledReply to <author>, with[ Reply ]/[ Cancel ]and no Delete, and the host posts throughIGitHubCli.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, likesr; a posted one closes it and the status bar saysReplied on #<n>.GitHubReviewThread.ReplyToIdis the thread's first comment as REST numbers it, read from the GraphQL query'sdatabaseId— 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 theTabsthen picks a different active tab. An outdated-threads document tab already open isn't rewritten —OpenDocumentonly focuses what it has. - In the Review tab,
ReviewTree.Buildtakes 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 inReviewRow.Display(the aspect, so type-to-jump sees it) and in the drawn row. Heading the count is a chat icon fromFileIcons.ForThreads(nf-md-chat/nf-md-chat_remove_outline, or 💬 / 💭), whichReviewView.DrawFiledraws into the row inDrawLine; with icons off,Displaywrites●/○in its place instead. Icon and count both takeReviewFileNode.BadgeStyle(bold while a thread is open, faint otherwise). The header gains<n> threads, <m> unresolved, and a file with outdated threads gets aReviewOutdatedNodechild that opens them in a document tab like the Overview's, with aReviewThreadNodeunder it per thread (<author>: <first line>) socchas 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) intoBranchReview.Viewed, which stays null without a PR. Toggle viewed (tv,Space,CommandScope.Review) on a file row callsIGitHubCli.SetViewedAsync(markFileAsViewed/unmarkFileAsViewed, naming the PR by the node idgh pr view --json idgives), 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, whereSpacedoes 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 saysViewed n of m(BranchReview.ViewedLine) aboveDraft review: …, laid out byReviewView.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 inReviewRow.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 fromFileIcons.ForChangeoverSyntaxHighlighter.EditorColors; with icons off (or noFileIcons) 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.ApplyTokenThemeredraws the tab on a theme switch. - Line counts (#393) come from
IGitCli.GetLineCountsAsync(git diff --numstat -z -Magainst 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.Countsdecides what shows (+N −M; only+Nfor an added file,−Nfor a deleted one, nothing for a pure rename, a faintbinfor a binary), inFileIcons.ChangeColors' added and deleted colours.DrawFilerebuilds each file row so they sit at the viewport's right edge — TG pads the row's cells to the viewport beforeDrawLine, 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.TotalsLineis 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.TotalsLabeloverridesOnDrawingTextto colour each+/−run like the file rows' counts.
- All I/O through
IFileSystemfromSystem.IO.Abstractions; never callSystem.IO.File/Directorydirectly.IFileInfo.FileSystemplumbs the same instance through toEditorTabetc. - DI registers
new FileSystem()singleton; tests build their ownMockFileSystem. EditorTab.Saveappends a final line break if non-empty and not already terminated (VS Codefiles.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:DetectEolfixes the EOL on load andSavere-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 aCtrl+Nfile gets.Normalizeis needed becauseTextView.Textre-joins lines withEnvironment.NewLine, so a CRLF file would otherwise become LF on Linux. In tests: assert exact bytes (\n/\r\n) for existing-file preservation, but assertEnvironment.NewLinefor new/empty-file output (it's intentionally OS-dependent).- Ctrl+S never overwrites a newer file without asking (#267).
EditorTabkeeps aFileSnapshotof the file as it last saw it — mtime, length and the content it read — taken on load and again after every write, soEditorTab.DiskNowreports someone else's change, never our own. mtime and length are only a screen: on a difference the file is re-read and compared, becausegit checkoutrewrites 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 butDiskState.Gone(there's nothing left to overwrite) andSavejust 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 throughIFileSystem'sIFileSystemWatcherFactoryandDiskChangesmarks the tab:● name ⚠, the name in the theme'sWarningcolour in the tab strip and the Explorer row, and one status line. The mark isDiskMark.For—nf-oct-alertunder a Nerd Font,⚠otherwise — andEditorGroup.IconStylekeeps it in step with the file-icon setting the wayGutterVisibledoes. 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 raisesError, 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-bytegit checkoutmark nothing.WorkbenchHost.ScheduleFlushschedules it withApp.AddTimeout, whose callback runs on the main loop — the hop back from the watcher's thread comes free with it. MockFileSystemhas no watcher of its own (it throws and tells you to supply a factory, but the property has no setter):WatchableFileSystemin the tests overrides it, so every rule above is asserted without a real disk or aThread.Sleep.
- 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
- 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 diskin the status bar.EditorTab.Reloadreads the file once throughFileSnapshot.ReadIfChanged(which shares its single pass over the file withDiskNow), keeps the cursor's line and column clamped to the new file — read before the text is replaced, sinceMoveCursorclamps — resets the gutter's baseline, re-detects the line-ending style, and clears the undo history, whichEditorTextView'sTextsetter 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:
DiskChangesruns the same routine onEditorGroup.ActiveTabChanged, and only whenDiskWatcher.IsWatchingreports 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 whyEditorGroup.TrackraisesTabsChangedbeforeAdd: 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 directory whose watcher failed catches up on activation, which is graceful degradation rather than a second mechanism:
- 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 samectsdiff, buffer against what's on disk now, and Reload is #269'sEditorTab.Reload— which is why that clears_dirtyas well: asked for over unsaved edits it drops them, so●and⚠both go and the nextCtrl+Sis silent. It goes throughDiskChanges.Reloadrather 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.ConfirmViewtakes aConfirmChoiceper way out — a label and what taking it does — and appends Cancel itself, laid out withPos.Align(Alignment.Center), so a row of one to four centres itself inside the 60-column modal. It raisesChosenrather 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 aTabsswitchesValueto 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), whichFileSnapshot.StateOfanswers andEditorTab.DiskMarkershows. A file that has gone is never reloaded — with nothing on disk the buffer is the only copy of it left — soDiskChanges.Acttakes theGonebranch before it asks whether the tab is dirty, and a dirty one keeps●,⊘and every edit.Ctrl+Swrites the buffer straight back with no modal: there's nothing on disk to overwrite, so there's nothing to ask about, which falls out ofDiskNowbeingGonerather thanChanged.Savecreates the file's directory first, since agit checkoutthat 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.
FileSystemWatcherreports it asRenamedcarrying the new path inFullPath, soDiskWatcher.OnRenamednotesOldFullPathas well; watching onlyFullPathwould 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
Actruns 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 whereReloadreturning false still changes what's on screen), and a dirty one becomes⚠as #268 does.
- A rename away is a deletion as far as a tab is concerned — we don't follow the file to its new name.
- Closing a dirty tab asks first (#409).
Ctrl+W/cfon anEditorTabwith unsaved edits opens aConfirmView:[ Save ] [ Don't save ] [< Cancel >], Cancel focused. Save goes through the same #267 check asCtrl+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,Editorscope, no default key) isDiskChanges.ReloadwithevenIfUnchanged, so a dirty tab over a file nobody touched still drops its edits. A clean tab just reloads; a dirty one confirms first throughConfirmView, Cancel focused; aGonefile 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.DirectoryNamein the filter, andDiskWatchermaps 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 forDirectoryNameand nothing else, so passing through$HOMEcosts no file events. - An in-app delete leaves no marker:
Workbench.Deletecloses the tabs throughEditorGroup.CloseUnderfirst, 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.
- 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
- Save all (#410) (
Ctrl+Shift+S,saf) saves every dirty editor tab the wayCtrl+Swould, skipping diff and document tabs, and reportsSaved N filesorNo 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 ontoCtrl+S, sosafis the way in there.
- 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
EditorSettingsrecord onISettingsService.Editor. They persist toTuiCode.settings.jsonbeside 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:WorkbenchHosthands them toEditorGroup.Settingsat startup and whenever Settings closes, and the group passes them to open and future tabs, likeGutterVisible. - Tab and Shift+Tab are the workbench commands Indent lines and Outdent lines (#386,
EditorTextView.Indent.cs), and TG's ownNextTabStop/PreviousTabStopon 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 areRowBlocks, 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 sameRowBlocksas 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 addsmarker + " "at each block's smallest indentation; blank lines are skipped. The marker isSyntaxLanguage.LineComment, read from the bundled package's VS Codelanguage-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 itsSyntaxLanguage.BlockCommentinstead:open + " "at the block's indentation and" " + closeat 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
NewLineinEditorTextView.NewLineKeepingIndent. Whitespace it put on an otherwise empty line is tracked by theList<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_recordedso it isn't a step of its own. - LF or CRLF converts a file's endings on its next save. Only Auto preserves them.
DocumentStats(TG-free) counts lines, words (wc -w's runs of non-whitespace) and characters for the buffer or the selections at every caret, viaEditorTab.CountDocument/CountSelection. It readsLineSnapshotstrings and splits graphemes withStringInfo, which has to agree with TG's cells, the unit ofColand of the range columns (CountDocument_counts_characters_in_the_same_unit_as_columns).di(Show document info, no default key) opensDocumentInfoViewfor 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), or3 selections (96 selected)with several carets).Workbench.ShowCursorPositionasks for it every iteration, soEditorTab.CountSelectioncaches its result against the caret ranges and an edit counter bumped inOnEdited, and recounts only when either changes.
TerminalFlowControlrunsstty -ixon -ixoffon Unix soCtrl+Sreaches the app. Restored on dispose. Mandatory.- Three-modifier combos require a capable terminal: iTerm2 / Ghostty / WezTerm / Alacritty on macOS; kitty / foot / GNOME Terminal with
modifyOtherKeyson Linux. macOS Terminal.app strips them and collapsesCtrl+Shift+letterontoCtrl+letter. - Option+←/→ never reach the app as
Alt+Left/Alt+Righton 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 whyAlt+Up/Alt+Downwork. Don't give a commandAlt+Left/Alt+Rightas its default (Revert change tookCtrl+Rinstead, #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 asMouseFlags.Alt, soCmd+Clickworks instead. The Getting Started dialog (HelpView) says so.- Driver selection (#82):
DriverSelection.ResolveinProgram.cspicks the TG driver and plumbs it intoApplication.Init(driverName). TG 2.1.0'sDriverRegistryregisters exactly three names —windows(Win32 Console API),dotnet(.NETSystem.Console),ansi(escape-sequence/kitty) — and TG's auto-select resolves toansion Windows, which mis-decodes kitty key events there (Backspace / Shift+letter / Ctrl+digit drop) despite kitty being fully negotiated. So on Windows we default towindows(IEnvironment.IsWindows), which reads keys via the Win32 Console API and decodes correctly in Windows Terminal; off Windows we passnull(TG auto). Precedence:--driver <name>/--driver=<name>flag >TUICODE_DRIVERenv var > per-OS default; a blank override falls through to the default. The name is pass-through — TG validates it (throwsArgumentExceptionon unknown, e.g. thev2win/v2netnames 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+0is eaten by the terminal's own zoom-reset (#81) — which is why it's no longer the sidebar-toggle key (use thetsmnemonic); WezTerm-on-Windows decodes poorly under every driver (tracked for the TG 2.2.x upgrade). Tests forceansivia the samedriverNamector param (the real WindowsDriver blocks on console input headless), so thewindowsdefault only ever runs throughProgram.cs, never in CI/tests.
tuicode [--flags] [<path>...]. Every argument that doesn't start with--is a path; everything else belongs to another parser (DriverSelection,TerminalIntegrationCli,--smoke/--smoke-syntax).--driveris the only flag whose value is a separate argument, soStartupArgumentsskips the one after it —--driver ansinever opens a file calledansi.UsageCli(Configuration/) answers--help/-h(#264): usage block to the injectedTextWriter, exit 0, ahead of every other parser so nothing has drawn on the terminal. Its text is a literal, wrapped to 80 columns;TerminalIntegrationCli.FlagsandDriverSelection.Flagare 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 takesIFileSystem, likeDriverSelection.Program.csruns it beforeApplication.Init, so the create prompt and any error reach a terminal nothing has drawn on yet: a path that can't be created printstuicode: permission denied: <path>to stderr and exits 1. Opening it is deferred to the first loop iteration (WorkbenchHost.OpenWhenRunning, which then callsWorkbench.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 tabOpenFolderrestored (TG 2.1.0 leavesHasFocusset 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.AskasksCreate 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 ruleCtrl+Nuses (FilePaths.IsDirectoryPath): a trailing slash means a folder, anything else a file (sotuicode Makefileworks), 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
grephands 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.StartupArgumentsresolves 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.OpenOrFocuswould too, but the parser is where it's testable.OpenStartupTargetopens 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 shapegrep -n, stack traces and CI logs print — puts the cursor there (#265).PathPosition.Splittakes the suffix off beforeStartupArgumentsresolves anything, and the whole "position or part of the name?" question is settled there, againstIFileSystem, so it's all testable onMockFileSystem. Three rules decide it: a path that exists wins outright (weird:42opens 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 isAssert.SkipWhen'd there); a suffix is a position only when it's all digits, so:abc,:-1and 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.MoveCursoris 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,:42and 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.
- Each supported emulator (iTerm2, WezTerm) implements
ITerminalIntegrationand is registered as a singleton inProgram.cs. Consumers (Settings UI + CLI) injectIEnumerable<ITerminalIntegration>straight from DI — no separate registry. Iterm2Integrationwrites~/Library/Application Support/iTerm2/DynamicProfiles/tuicode.jsonviaIFileSystem; tests passMockFileSystem+FakeEnvironment. Stable GUID; staleness detection via aTuiCodeIntegrationVersionmarker inside the JSON.Bound Hostslists both&TuiCode*and&tuicode*so the brew-renamed binary still matches (iTerm2's matcher is case-sensitive).WezTermIntegrationwrites~/.config/wezterm/tuicode.luaonly — it deliberately doesn't touchwezterm.lua(WezTerm users treat that file as personal config). The user pastes a one-liner (require 'tuicode'.apply(config)) themselves; the snippet is surfaced viaITerminalIntegration.PostInstallInstructions, rendered in the Settings panel and printed by the CLI installer. The module registers atuicodekey table and auser-var-changedhandler.WorkbenchHostemits OSC 1337SetUserVar=TUICODE_ACTIVE=1on startup (after_app.Init) and=0on 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 needset -g allow-passthrough onfor the OSC to reach WezTerm.- CLI surface in
TerminalIntegrationCli—--install-/--uninstall-/--list-/--check-terminal-integration[=id].Program.csruns it before TG init and exits on hit; the--checkflag returns 0/1/2 for installed/stale/not-installed. - Settings UI:
TerminalIntegrationPickerViewshows only the detected terminal (per #59). Buttons act onITerminalIntegrationdirectly — no staging viaISettingsService.Save, because the write is to an external app's config, not a TuiCode setting. Rendering decisions are split into the pureTerminalIntegrationPanelState.Buildso unit tests don't need TG.
SidebarSizing(Abstractions) holds the limits and the clamp: default 30, floor 15, 5 a step, and a maximum ofmax(15, terminalWidth - 40)so the editor keeps 40 columns — except on a terminal too narrow for both, where the sidebar's floor wins.Workbenchkeeps two widths.SidebarWidthis what the user asked for;DrawnSidebarWidthis that clamped to the terminal, re-computed inOnSubViewsLaidOutso shrinking the window never starves the editor and widening it back restores the choice. The clamp never writes back toSidebarWidth.Widen sidebar/Narrow sidebar(ws/ns, unbound by default) nudge from what's on screen and persist throughISettingsService.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.OnAppMouseEventis the mouse's single subscription, the twin ofOnAppKeyDown:App.Mouse.MouseEventfires before any view sees the event andHandledshort-circuits the whole ofRaiseMouseEvent, 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 ownBorderView— 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 haveViewArrangement.RightResizable, but itsArrangertakes 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.
DefaultSettingsServiceis a thin wrapper around TG's staticConfigurationManager/ThemeManager.Themegetter/setter delegate straight toThemeManager.Theme; no backing field.Load()callsConfigurationManager.Enable(ConfigLocations.All);Program.csinvokes it on the resolved service before constructing the App, soThemeManager.Themeis in place whenApplication.Init()paints.ThemeManager.Themeis 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 atDefaultSettingsServiceconstruction, written onSave()) — TG's serializer can't round-trip them. Diff style:{ Keys, Label, Command }, prefixCommandwith-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:BindDefaultsthenApplyKeybindings(settings.KeybindingOverrides). Picker callsWorkbenchHost.ApplyEditedBindingson 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 likeCtrl+Alt+Shift++(0xD000002B) stringifies to"Ctrl+Alt+Shift++", whichKey.TryParsecan't read back (a TG round-trip bug), so a display-string identity made such chords un-bindable and crashed on save. SoKeyBinding/KeybindingOverridecarry anIReadOnlyList<Key>chord;KeyChord.Canonical(keycode-joined) is the identity used for binding, matching, diffing, dedup, and persistence, whileKeyChord.Display(ToString()-joined) is UI-only and never parsed back.IKeybindingServiceexposesKey-listBind/Unbind/CheckConflictas the canonical path; thestringoverloads remain as sugar for the hardcodedBindDefaults(safe literals). The picker feeds its capturedList<Key>straight in — no stringify round-trip. Persistence writes"Keys": [<uint keycode>…]plus a decorative"Label"(the display string, ignored on load); load reconstructs eachKeyfrom its keycode vianew Key((KeyCode)u), which is lossless. - Pre-#89 files are dropped, by design. An old
{ "Key": "<display>", "Command": … }entry has no"Keys"array, soLoadKeybindingsskips 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-timetry/catchguarded (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.jsonviaWorkspaceStateStore: a JSON array, most recently used folder first, capped atMaxFolders(30). The launch folder always wins (likecode .);Workbench.OpenFolder(startup and the in-app Open) restores that folder's tabs, skipping files that are gone. Saves happen on everyActiveTabChanged(open, close, switch), so a crash loses nothing.Workbenchnulls 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) listsWorkspaceStateStore.Folders()asRecentFolderPickerView, 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 isWorkbench.OpenFolder. - Open file path (#358) (
opa, no default key;opwould shadowopr) is aPathPromptViewfor a folder to open by its full path;FolderPath.Expandturns a leading~into the home folder and drops a trailing separator. A relative path, a missing folder or a file isNo such folder: <path>in the prompt, which stays open; Enter otherwise isWorkbench.OpenFolder. - Don't reach into
ConfigurationManager/ThemeManagerfrom feature code — go throughISettingsService. Tests useInMemorySettingsService. - Tests that mutate
ThemeManager.Theme(e.g.DefaultSettingsServiceTests) must use[Collection("StaticConfiguration")]plus a localThemeFixtureto snapshot+restore the static. Keybindings are instance state on the service, so keybinding-only tests need neither.
- 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.Loadmigrates a saved theme we don't ship (TG'sLight→ 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 asConfigurationManager.RuntimeConfig. TG mergesThemesper theme, and the file sets noThemekey, so the user's saved choice still wins. The editor half is a VS Code-style token theme insrc/TuiCode.Syntax/Themes/(embedded asthemes/<file>, checked before the zip). ItstokenColorscolour syntax, and itscolorscarry what TG schemes can't:editorCursor.foregroundand the gutter keys.BundledThemes.TokenThemeForpairs them. Adding a theme means touching both files,BundledThemes, and the tests' theme lists. - Token themes may
includeothers (midnight.json→dark_plus.json→dark_vs.json). TextMateSharp resolves only one level, soGrammarBundle.GetThemeflattens the chain itself, appendingtokenColorsand letting a theme'scolorsoverride its base. - Parts name their own schemes:
SidebarPartusesSidebar,StatusBarPartusesStatusBar; a missing scheme falls back to the SuperView's. Every bundled theme defines all ofBase,Accent,Dialog,Menu,Error,Warning,Sidebar,StatusBar(a test enforces it). TG has noVisualRolefor a warning, soWarningis read by name throughWarningColour.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.
WorkbenchHostsends OSC 12 with the theme'seditorCursor.foregroundat startup and on everyISettingsService.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 withOSC 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
HOMEpointed at a scratch directory andTUI_CONFIG='{"Theme":"Turbo Pascal"}'(TG loads that env var after the user file).
Program.csis the only DI consumer.Workbench(the rootWindow) 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.WorkbenchHostownsIApplication, 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.cscallsservices.AddLogging()with no provider, soILoggermessages are captured through the abstraction but don't surface anywhere yet — picking the sink (file / status bar / diagnostics view) is tracked in #92. InjectILogger<T>to record events (first use:WorkbenchHostlogging skipped malformed keybinding overrides, #90); don't reach for the status bar to report background/diagnostic conditions.
- In-editor cursor navigation (Home/End/Ctrl+arrows + Shift selection variants) comes from TG's built-in
TextViewbindings — we don't bind these ourselves. Ctrl+Gis a chord family (#35):Ctrl+G LopensGoToLineView(1-basedline[:col]input),Ctrl+G P/Ctrl+G Nstep back/forward through cursor history, andCtrl+G Bblames the cursor's line (see Git).EditorTab.MoveCursor(row, col)writes throughTextView.InsertionPoint, which is aPoint(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:42andEnterin a diff. It scrolls so the range's first line sitsReveal.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, inTuiCode.Editor) is the whole of the calculation and owns the margin the diff pane reads too. It runs after the cursor move, since TG'sScrollToMakeCursorVisiblewould 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, offViewportChanged. - A jump never carries a selection (#360):
EditorTab.MoveCursordrops extra cursors and the selection before it moves, andSelect(Find results) starts from no selection, so the match is all that's selected.Selectsets TG's private_shiftSelectingas 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 likeMnemonicResolver.WorkbenchHostfeeds it: within-file moves arrive viaEditorGroup.CursorMoved(re-raised fromTextView.UnwrappedCursorPositionChanged, which reports unwrapped model coords), file switches viaActiveTabChanged. 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 passesexplicitJump: trueso 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_suppressHistoryso 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 fromQuickOpenIndex.Scan— breadth-first, skippingWorkspaceSearch.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,Editorscope) opensSymbolPickerViewon the definitions in the active file: aTextFieldfilter over aListView, the shape ofRevisionPickerView/PullRequestPickerView, filtered by the sameCamelHumpsas 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 toplaintext) are one status-bar line each; a file that has a grammar but no definitions opens the picker and saysNo symbols in this filein theAlertViewat its foot once the scan is over. The hint bar is still a one-rowLabel, like its sibling pickers (#208). - The definitions come from
SymbolScan(TuiCode.Syntax, TG-free, per-language tests inSymbolScanTests), which re-tokenizes the buffer withTokenizeLine— the scoped call the colour path doesn't use, which keepsTokenizeLine2. 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, someta.functionmatchesmeta.function.definitionbut notmeta.function-call— Rust's calls and Python'sprintboth 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.csto bothDoWorkAsync's declaration andConsole.WriteLine. The fallback is the line's first name that isn't a type, and only on a line that has already declared something — astorage.modifier,storage.type,keyword.typeorentity.name.typeahead 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 passesvar y = Compute(x);(vardeclares), the first rejects it (yis 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 —SymbolKindsplitsentity.name.type.{class,interface,enum,struct,trait}into a member apiece rather than oneType. - 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.Renderleaves the columns for it andSymbolListSourcedraws 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: insidemeta.interfaceit's a property, anywhere else a field (#226). Rust's struct fields are plainvariable.otherand Python's class attributesmeta.attribute, neither of which says "declaration", sogsstill 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 (
Titleover=====) is scoped on its underline with the text on the line above.SymbolScan.AddHeadingtakes the text between the first and lastentity.name.sectiontoken, which drops the#s at both ends and keeps## C#whole, and the level from theheading.<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 carriesmeta.class.ts, while C# gives a method justsource.cs | entity.name.function.csand 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 toSymbolScan.MaxRetries— a cold grammar otherwise drops the symbols on its first lines. The result isEditorTab.ScanSymbols(), cached against the tab's edit count and grammar, so a secondgswithout an edit opens on a finished list. - The status bar's
Ln X, Col Y(right-aligned, #120) is polled onIApplication.Iteration(Workbench.ShowCursorPosition), not driven byCursorMoved: TG 2.1.0 raisesUnwrappedCursorPositionChangedfrom its key/mouse paths only, so programmatic moves (find, Go-to-line, multi-caret, line moves) go unreported.StatusBarPart.SetPositionno-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+Rightbefore iTerm2 sees them. Disable in System Settings → Keyboard → Keyboard Shortcuts → Mission Control.
FocusService(Workbench/Focus/) is the single source of truth for the focused region —Editor,Diff,Explorer,Find,FindBar,RevieworTabs.FindBaris the find bar over the active file andFindthe sidebar's pane; both readFindin the status bar, and because the bar sits inside the active tab, theEditorandTabsownership tests have to exclude it.WorkbenchHost.FocusedScopereads it (FocusService.ScopeOf) instead of polling the views, and the status bar's first word and the focused pane's border both come from itsRegionChanged.- 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.OpenFileused to focus the tab it opened; it raisesFileOpenedinstead 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.Showmoves no focus at all; it raisesRefreshed, 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 fromghseconds after you've moved to the diff (#196). - Clear a stale
HasFocusbefore taking the keyboard. TG 2.1.0'sSetFocusis 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.Takedrops 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 leavesHasFocusset on a view focus has moved on from, soFocusService.Reconcilere-reads the region from what TG says is focused — on everyIterationand before each key is dispatched, since a mouse click lands between iterations.Focusrecords optimistically; a move that didn't land shows up asUnreachableand 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 focusedSidebarPartover the explorer inside it). Walk down withView.MostFocused—DiffTab.IsFocusedgets away without it only because aDiffTabis itself focusable.Tabsis reached one way, theftcommand (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 itsCommandScopeisEditor— the scope those keys already fired in.PaneTabsclaimsUp/Down/Left/Rightand 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 ourTabsare one:EditorGroup, whereUporLeftat the very start of a buffer —TextViewdeliberately 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'sTabsbinds all four to tab switching and lists them inCommandsToBubbleUp, and under that the application bindsCursorUp/CursorLefttoPreviousTabStopandCursorDown/CursorRighttoNextTabStop, which moves focus to another tab and hasTabs.OnFocusedChangedfollow 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
FocusServicekept the region, so the bar saidReviewover 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.OnTabStriptells 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
GettingAttributeForRolehook on the pane's border view (FocusBorder), swappingNormal→Focusas the attribute is resolved. TG draws border lines inNormalwhatever has focus; overriding the scheme instead would have to be reapplied on every theme change.
- Every assembly's version comes from MinVer (root
Directory.Build.props), which reads the latestv*tag: on the tagged commit that's0.0.4, three commits past it0.0.5-alpha.0.3.MinVerTagPrefixisv, since MinVer defaults to unprefixed tags. About (tui) shows it throughWorkbenchHost.AppVersion, so a build says which worktree it came from rather than the SDK's1.0.0. - MinVer needs the tags, so every job that builds the app checks out with
fetch-depth: 0; without them it falls back to0.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-alphaversion in a release archive.
.github/workflows/release.ymlruns from the Actions tab only (workflow_dispatch, with abumpchoice) — see CONTRIBUTING.md § Cutting a release. It calls a-team's reusablerelease-version.ymlfor 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.gzon Unix,.zipon Windows) with a.sha256sidecar, then callsrelease-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 onmentaldesk/a-teamand re-pinned here, not forked into this repo. - A called workflow doesn't inherit the caller's permissions, so the
versionjob declarescontents: read+pull-requests: readandpublishdeclarescontents: write. publishruns only frommain, in thereleaseenvironment, which onlymaincan deploy to. The packages token is that environment'sPACKAGES_TOKENsecret, so a workflow on any other branch can't read it.publishpassessecrets: inheritbecause that's the only way an environment's secrets reach a called workflow (actions/runner#4453).- Shared
publishcreates the tag itself (gh release create --target $GITHUB_SHA), so the caller must never tag, and the build matrix isfail-fast: true: a platform that fails leaves no tag behind to clean up.concurrency: releasestops 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— fornotarytool submit. App-specific password from appleid.apple.com.
- Archives also carry
THIRD-PARTY-NOTICES.mdandDOTNET-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'sPublishThirdPartyNoticestarget 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'saotjob checks they're there. - The Libraries section of
THIRD-PARTY-NOTICES.mdis maintained by hand: when a package that ends up in the binary is added (checksrc/TuiCode/obj/project.assets.jsonfor 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-armrunners — native, no cross-compile. packaging/tuicode.rbis the tap's formula andpackaging/tuicode.jsonthe bucket's manifest, with the version and SHA256s replaced by{{version}}and{{sha_<rid>}}, which sharedpublishfills from the archives it downloaded. It fails the job on a placeholder no archive matched, andPackagingTemplateTestschecks every placeholder against the build matrix so a typo surfaces in CI instead of mid-release. The formula has noversionline —brew audit --strictrejects 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
PackagingTemplateTestsalso checks it parses as JSON once rendered.PACKAGES_TOKENneeds Contents write onmentaldesk/scoop-bucketas well as the tap; the shared workflow only checks the token is present, so one that can't reach the bucket failsactions/checkoutand reddens the release.
- The Linux legs build on
ubuntu-22.04/ubuntu-22.04-arm, notubuntu-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'saotjob fails if the binary references aGLIBC_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 loadslibicu, which minimal images lack. Counts and sizes formatted withCurrentCulturetherefore 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-syntaxand--smokeindebian:12,ubuntu:22.04andfedora:latestcontainers with nolibicu, on x64 and arm64. - Invariant mode also makes culture-aware comparisons ordinal and
new CultureInfo("xx")throw, so don't depend on either.
- Work in a dedicated worktree, never on
mainor 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.