This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Pre-alpha. The document layer (rope-backed TextDocument and supporting types from AvaloniaEdit) is landed and tested. Editor : View consumes it and supports keyboard-driven editing (caret nav, insert, delete, backspace, Enter, undo/redo) plus scrolling. Still pending per specs/00-plan.md: selection, multi-caret, folding, search, indentation strategies, syntax highlighting, the VisualLineBuilder / transformer / background-renderer pipeline. When adding code, follow the plan; don't invent alternative architectures without updating the spec.
Active development happens on develop. main is the release/stable branch.
- Work on
develop. During pre-alpha, direct commits and pushes todevelopare allowed — no PRs required for routine work. - Do not push directly to
main. Promotion fromdeveloptomainis a deliberate release step. - Two paths trigger
.github/workflows/release.yml, which builds + tests cross-platform, then packs and pushes both NuGet packages:- Push a
v*tag (e.g.v2.1.0) — canonical stable release; version = tag minus leadingv. - Push to
develop— rolling pre-release; version =<Version>fromDirectory.Build.props+.${github.run_number}. With base2.1.1-develop, the first run publishes2.1.1-develop.1, etc. workflow_dispatchis also available with a verbatim version input.
- Push a
Directory.Build.props holds a single <Version> shared by both packages. Track Terminal.Gui's version stream — when the latest stable Terminal.Gui is X.Y.Z, our develop base is the next-patch pre-release (e.g. TG 2.1.0 → our base 2.1.1-develop). Bump the base when TG ships a new stable, not on every commit. The .${run_number} suffix is the per-build counter, applied automatically by the workflow.
<TerminalGuiVersion> (also in Directory.Build.props) pins the Terminal.Gui dependency. Bump it when the project is ready to consume a new TG release; CI/release workflows can override via -p:TerminalGuiVersion=<x> if needed.
Requires the .NET 10 SDK (preview). Solution file is Terminal.Gui.Editor.slnx (XML solution format, not .sln).
dotnet restore Terminal.Gui.Editor.slnx
dotnet build Terminal.Gui.Editor.slnxTests are xUnit.v3 and run as executables (each test project sets <OutputType>Exe</OutputType>). Use dotnet run, not dotnet test:
dotnet run --project tests/Terminal.Gui.Editor.Tests
dotnet run --project tests/Terminal.Gui.Editor.Tests
dotnet run --project tests/Terminal.Gui.Editor.IntegrationTests
dotnet run --project tests/Terminal.Gui.Editor.PerformanceTests -c ReleaseThe PerformanceTests project is stopwatch-based and only meaningful in Release. It runs in
the dedicated .github/workflows/perf.yml workflow (ubuntu-latest only), separately from the
correctness-focused ci.yml. See the "Testing tiers" section below.
Run a single test by passing xUnit.v3 filter args after --:
dotnet run --project tests/Terminal.Gui.Editor.Tests -- -method "*MyTestName*"CI verifies formatting with dotnet format Terminal.Gui.Editor.slnx --verify-no-changes --exclude third_party/. Run the same locally before pushing if you've touched C# files outside third_party/.
Two NuGet packages with a strict dependency direction:
src/Terminal.Gui.Editor— UI-framework-independent document model. NamespaceTerminal.Gui.Editorand subnamespaces. Must not reference Terminal.Gui. Holds the rope-backedTextDocument,DocumentLine,TextAnchor,UndoStack,ITextSource,TextSegment, theRope, and supporting utility types. Lifted from AvaloniaEdit (see fork policy below) —Document/andUtils/are landed;Folding/,Search/,Indentation/,Highlighting/are follow-up phases perspecs/00-plan.md.src/Terminal.Gui.Editor— theEditor : Viewand cell-grid rendering pipeline. NamespaceTerminal.Gui.Views(matches Terminal.Gui convention, deliberately notTerminal.Gui.Editor). ReferencesTerminal.Gui(version pinned via$(TerminalGuiVersion)inDirectory.Build.props) andTerminal.Gui.Editor. Split into partials:Editor.cs(core:Document,CaretOffset, edit-tracking arithmetic, content-size + scroll),Editor.Drawing.cs(OnDrawingContent+ cursor positioning),Editor.Keyboard.cs(OnKeyDownswitch — navigation / editing / undo+redo). No selection / folding / highlighting / multi-caret yet.examples/ted— standalone TG demo app exercisingEditor. Not packed; not a NuGet artifact. Has a File menu, theEditorView, and a status bar; grows with the View. Run viadotnet run --project examples/ted.
The boundary matters: anything that takes a dependency on Terminal.Gui types belongs in Terminal.Gui.Editor, never in Terminal.Gui.Editor.
Current (pre-MVP): Editor.OnDrawingContent walks visible DocumentLines directly via Document.GetLineByNumber, slices each line by the horizontal Viewport.X, and AddStrs. Caret math is integer offset → (line, col) via Document.GetLineByOffset; sticky virtual column preserves the user's intended col across vertical moves through short lines.
Planned (per specs/00-plan.md §6): DocumentLine → VisualLineBuilder → CellVisualLine (one or more CellVisualLineElements). IVisualLineTransformers mutate element Attributes (highlighting, folding markers); IBackgroundRenderers paint cell rectangles (selection, current line, search hits). All measurement is in cells, not pixels — use grapheme clusters and string.GetColumns(). AvaloniaEdit's TextRunProperties (typeface, brushes, font size) collapses to a single Terminal.Gui.Attribute. Visual lines cached + selectively invalidated from the Document.Changed offset+length range. Caret eventually becomes a TextAnchor (AnchorMovementType.AfterInsertion); selection a TextSegment of two anchors; multi-caret runs commands inside a single Document.OpenUpdateScope () so undo collapses to one step.
See specs/00-plan.md §6 for the planned pipeline and full Editor public API sketch.
Code is lifted from AvaloniaEdit into the relevant src/Terminal.Gui.Editor/ subfolders (Document/, Utils/ so far; Folding/, Search/, Indentation/, Highlighting/ to follow). The pinned upstream commit and per-file modification log live in third_party/AvaloniaEdit/UPSTREAM.md (with the upstream MIT LICENSE alongside).
For lifted files:
- Preserve original formatting and copyright headers. House-style reformatting defeats the merge story.
- Add the line
// Adapted for Terminal.Gui from AvaloniaEdit <commit-sha>under the original header. - Targeted edits only: strip
using Avalonia.*, removeDispatcher.UIThread.VerifyAccess ()calls, replaceIBrush/Avalonia.Media.ColorwithTerminal.Gui.Color, drop typeface/font-size fromHighlightingColor. - Log every modification in
third_party/AvaloniaEdit/UPSTREAM.mdalong with the pinned upstream commit.
The fork is hard — re-syncs are manual and deliberate, triggered only by upstream fixes we want.
Adopts Terminal.Gui's house style. Three enforcement layers:
.editorconfig+dotnet format— formatting, var, expression-bodied, collection expressions, modern syntax preferences. CI runsdotnet format --verify-no-changes.Terminal.Gui.Editor.slnx.DotSettings+dotnet jb cleanupcode— ReSharper-driven cleanup ("TG.Editor Full Cleanup" profile). Catches whatdotnet formatmisses (XML doc spacing, using sorting, name qualifier removal, expression-bodied conversions). CI runsdotnet jb cleanupcodeand fails on any diff.- A Stop hook in
.claude/settings.jsonthat runs both tools on .cs files modified during the session before the agent reports done. Output is suppressed unless the cleanup actually changed something.
Before declaring work complete, an agent must run dotnet tool restore && dotnet format Terminal.Gui.Editor.slnx --exclude third_party/ && dotnet jb cleanupcode Terminal.Gui.Editor.slnx --profile="TG.Editor Full Cleanup" (the Stop hook does this automatically). If the cleanup adjusts files, those changes are part of the work — re-stage and continue.
- Space before
()and[]:Method (),array [i],new (),nameof (x),typeof (T). - Allman braces everywhere. No single-line
if (x) Foo ();. - Blank line before
return/break/continue/throw; blank line after a control block before the next statement. - XML doc tags carry a space before
/>:<see cref="X" />,<paramref name="x" />,<see langword="true" />. (dotnet jb cleanupcodeenforces this;dotnet formatdoes not.)
varfor built-ins only (int,string,bool,double,float,decimal,char,byte). Use the explicit type for everything else (DocumentLine line = ...,Rectangle viewport = ...). Thedotnet formatrule is:warning, so deviation fails CI.new ()(target-typed) when the LHS or context makes the type obvious:Editor editor = new ();,_lines = new List<int> ();. Do not writenew Editor ()on the right ofEditor editor = ....- Specify the type on
newwhen it is not obvious from a few characters of context. Examples where the explicit form wins: arguments to overloaded methods (Foo (new SomeRequest { ... })), return statements where the method's return type is far away (return new TextSegment { ... };), or object-initializer-only constructions used as the only argument to a generic. - Collection expressions
[...]instead ofnew[] { ... }/new List<T> { ... }. Forparamsarrays of literals:Foo ([1, 2, 3]). For empty:[]. For spread:[..a, ..b, x]. Applies anywhere a collection-expression target type exists (IEnumerable<T>, arrays,Span,ImmutableArray, etc.). - Modern string features. Prefer raw string literals
"""..."""for any string containing escapes / quotes / multi-line content. Prefer interpolation$"..."overstring.Format. Preferstring.Createonly when measurable allocation matters. PreferReadOnlySpan<char>parameters overstringfor perf-sensitive parsers. - Pattern matching over null + cast:
if (x is Foo f)notif (x != null && x is Foo);obj is { Prop: var p }for member extraction; switch expressions over chainedif/else if. - Range/index operators:
s[..^1],arr[1..], nots.Substring (0, s.Length - 1).
When a property has a setter with logic (validation, equality check, side effects), use the field keyword:
public int IndentationSize
{
get;
set
{
ArgumentOutOfRangeException.ThrowIfLessThan (value, 1);
if (field == value) return;
field = value;
SetNeedsDraw ();
}
} = 4;Do not introduce a separate private int _indentationSize; field. The initializer trails the close-brace as shown.
When an explicit backing field is unavoidable, declare it immediately above the property it backs — never in a field block at the top of the type. The field keyword is the default and removes the question entirely; but some properties still need a real field (a lifted/forked file that preserves upstream style and does not use field; a field shared by several members; a field touched by ref/out or interlocked ops). In those cases the field/property pair stays visually together:
private VisualRole? _role;
/// <summary>...</summary>
public VisualRole? Role
{
get => _role;
set
{
if (IsFrozen)
{
throw new InvalidOperationException ();
}
_role = value;
}
}Fork-policy exception: do not reflow a lifted file's existing upstream field block to satisfy this — that churns the merge story (see the AvaloniaEdit fork policy). The rule binds new fields you add, even inside lifted files.
ReSharper bug warning. ReSharper / Rider's "Convert to auto-property" and "Use auto-property" inspections are unreliable around
fieldand may rewrite intentionalfield-backed properties into broken auto-properties. The team-shared.DotSettingsdisables these inspections (ConvertToAutoProperty,ConvertToAutoPropertyWhenPossible,ConvertToAutoPropertyWithPrivateSetterset toDO_NOT_SHOW;CSUseAutoPropertycleanup step disabled). If you see the suggestion in your IDE, ignore it and check that your local Rider is using the team-shared settings.fieldis not optional in this codebase.
For trivial getters with no setter logic, plain auto-properties are fine: public TextDocument? Document { get; private set; }.
- Properties / accessors / lambdas: expression-bodied (
=>) when the body is one expression that fits on a single line. - Methods / constructors / operators / local functions: block-bodied (
{ ... }) even if the body is a single expression. Block-bodied keeps tracebacks readable, lets debuggers set breakpoints on individual statements, and avoids churn when a future edit needs to add a second line.
// Property — expression-bodied is fine.
public bool HasSelection => _selectionAnchor is { } a && a != _caretOffset;
// Method — block body, even for a one-liner.
private void ExtendCaretBy (int delta)
{
ExtendCaretTo (_caretOffset + delta);
}- Guard clauses; never wrap the happy path in
if. If a method's success path is wrapped inif (cond) { ... return X; } return Y;, invert toif (!cond) return Y; ... return X;. Example before/after lives in PR history (OnKeyDownNotHandled, commit4f600ab). - Return early on null/empty/invalid input. No nested-if pyramids.
- One return per logical branch is fine; one return per method is a non-rule — readability wins.
- One public or internal type per file. No nested types except inside the file that owns the outer type, and only when the nested type is a private implementation detail (
DocumentLine.LineNode-style). If a nested type grows interesting, promote it to its own file. - No file longer than 1000 lines. When a file approaches that, split — by partial class (
Editor.Drawing.cs,Editor.Mouse.cs), by helper extraction, or by genuinely splitting the type. The cleanup hook does not enforce this; the reviewer does. - C# 14
extensionblocks: prefer extension blocks over a static class full ofthis-prefixed extension methods when the extensions form a coherent group on a single receiver type. - Namespace per folder.
src/Terminal.Gui.Editor/Document/⇒Terminal.Gui.Document;src/Terminal.Gui.Editor/Rendering/⇒Terminal.Gui.Views.Rendering. Don't put unrelated types in the same namespace just because they share a folder. - No static members on
View-derived types. A class that derives fromTerminal.Gui.View(e.g.Editor) must not declarestaticmembers — not fields, not properties, not events, not even "harmless" caches or lookup tables. Terminal.Gui'sApplicationlifetime is per-instance (see "Testing tiers"); static state on a View is process-global, survives acrossIApplicationinstances, and silently couples otherwise-independent windows and parallel tests (the canonical cause of parallel-test hangs). Shared/lookup data lives in a dedicated non-View type (e.g.XshdRoleMap), exposed read-only (private+FrozenDictionary/IReadOnlyXxx), and is injected or queried — never hung off the View.constis the only exception (it is not state). This is a hard rule; a reviewer blocks on it.
- AI-generated tests marked
// Claude - <model>or// CoPilot - <model>at the top of the file (seetests/Terminal.Gui.Editor.Tests/SmokeTests.csfor the format).
Four test projects, mirroring Terminal.Gui's convention. The correctness projects all run fully in parallel — Terminal.Gui's Application lifetime is per-instance (Application.Create() returns an IApplication whose Init/Begin/End/Dispose track via ThreadLocal<>, not process globals). Tests must never call the static Application.Init() shortcut, and must never enable ConfigurationManager (CM.Enable(...)) — both reach for process-global state and would force serialization.
Terminal.Gui.Editor.Tests— pure, no UI, no static state. Target ≥90% coverage. Runs inci.yml.Terminal.Gui.Editor.IntegrationTests— full key-input → render scenarios viaAppFixture<T>, which boots a per-testIApplicationfromApplication.Create(). Parallel by default. Runs inci.yml.Terminal.Gui.Editor.PerformanceTests— stopwatch-based perf smoke tests. Release only, ubuntu-latest only. Lives in its own project and its own workflow (.github/workflows/perf.yml) because Windows/macOS GitHub-hosted runners are too noisy for wall-time assertions. The BenchmarkDotNet suite inbenchmarks/runs from the same workflow.
New tests default to the parallel-by-name project. Promote to IntegrationTests only when an IApplication (driver, input injection, full layout/draw) is genuinely needed. Promote to PerformanceTests only when you need a wall-time assertion — and remember it won't run on Windows/macOS CI, so don't put correctness checks there.
The one allowed exception: a test that legitimately mutates a process-global (e.g. Logging.Logger, Trace.EnabledCategories, anything static) must opt out of cross-collection parallelism via a [CollectionDefinition(name, DisableParallelization = true)] + [Collection(name)] pair. See tests/Terminal.Gui.Editor.IntegrationTests/HostingTests.cs for the canonical example. Do not add an assembly-wide xunit.runner.json to make the whole project serial — that's the wrong tool for one offending class.
Two layers, both in .github/workflows/perf.yml:
Terminal.Gui.Editor.PerformanceTests— stopwatch smoke tests with deliberately loose thresholds (~5× typical wall time). They catch catastrophic regressions, not 10% drift.benchmarks/compare-baseline.sh— runs the focused*VisualLineBuild*BenchmarkDotNet filter and compares againstbenchmarks/baseline.json. Fails on >3× regression, celebrates on <0.8× improvement. Run--job short(lowercase) —ShortRunmakes BDN reject it and the comparison silently no-ops.
The full BenchmarkDotNet matrix (Scrolling, EndToEndScroll, CaretMovement, DocumentAccess) is opt-in via workflow_dispatch on the perf workflow with full-suite: true. That's the operator path for refreshing baseline.json — run, download artifact, commit the numbers.
When integration tests run individually but hang when run as a suite, the cause is almost always shared mutable state, not the parallelism itself. Do not reach for xunit.runner.json with parallelizeTestCollections: false to "fix" it — that hides the bug and slows the suite. Walk this checklist instead:
- Static
Application.Init()? Grep forApplication.Init(without anIApplicationreceiver). It must always beapp.Init()on anIApplicationfromApplication.Create(). The static form is a process-global Init/Shutdown pair and serializes everything. ConfigurationManager.Enable(...)? Grep forConfigurationManager.Enable/CM.Enable. CM is a process-global config store; tests must never enable it. Enabling it from one test poisons every concurrentApplication.Create().- Mutating TG-read process globals?
Logging.Logger,Trace.EnabledCategories, and anythingstaticonApplication/Terminal.Gui.*that TG itself reads during draw or lifecycle. A test that swaps these (even with try/finally restore) will deadlock or corrupt parallel tests because TG running on another test's thread reads the half-set value. - A new
Viewsubclass touching shared state? Subscribing to a static event, allocating from a static cache, etc.
Bisect by running test classes pairwise (-class "*A" -class "*B") until you find the offending pair, then narrow by -method. The hang is reproducible deterministically with the right pair.
The fix is always to remove or isolate the global mutation — either eliminate it, or wrap the offending class in [CollectionDefinition(..., DisableParallelization = true)] + [Collection(...)] so only it serializes. The rest of the suite stays parallel.
Don't accidentally do these — they were considered and rejected:
- Source/API compatibility with
Terminal.Gui.TextView.Editorships beside it, not as a replacement. - RTL bidi or rich text shaping beyond grapheme width.
- Pixel/proportional font fidelity.
- Porting AvaloniaEdit's
Editing/,Rendering/, orCodeCompletion/namespaces — those are Avalonia-UI-specific and replaced by TG-native equivalents (Editorpartials, cell-gridRendering/,PopoverMenufor completion).
specs/00-plan.md §10 lists open design questions (line-ending policy, xshd vs TextMate for first highlighter, async I/O placement, read-only ranges, completion item shape). Resolutions go in specs/05-decisions.md (not yet created). If a task touches one of these, surface the decision rather than picking unilaterally.