Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ What follows is how TuiCode implements it today.

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

Expand Down Expand Up @@ -429,7 +429,7 @@ What follows is how TuiCode implements it today.
## Navigation

- In-editor cursor navigation (Home/End/Ctrl+arrows + Shift selection variants) is Editor's commands on TextView's keys (`CodeEditor.TextViewKeys`), not workbench bindings.
- `Ctrl+G` is a chord family (#35): `Ctrl+G L` opens `GoToLineView` (1-based `line[:col]` input), `Ctrl+G P`/`Ctrl+G N` step back/forward through cursor history, `Ctrl+G D` goes to a definition, `Ctrl+G U` finds usages, `Ctrl+G B` blames the cursor's line (see *Git*), `Ctrl+G H` shows the file's history (see *File history*), and `Ctrl+G A` shows or hides AI assistance (`ai`). `EditorTab.MoveCursor(row, col)` writes through `CodeEditor.InsertionPoint`, which is a `Point` (Column, Row) — not an int offset. Out-of-range row/col clamp.
- `Ctrl+G` is a chord family (#35): `Ctrl+G L` opens `GoToLineView` (1-based `line[:col]` input), `Ctrl+G P`/`Ctrl+G N` step back/forward through cursor history, `Ctrl+G D` goes to a definition, `Ctrl+G I` shows quick info, `Ctrl+G U` finds usages, `Ctrl+G B` blames the cursor's line (see *Git*), `Ctrl+G H` shows the file's history (see *File history*), and `Ctrl+G A` shows or hides AI assistance (`ai`). `EditorTab.MoveCursor(row, col)` writes through `CodeEditor.InsertionPoint`, which is a `Point` (Column, Row) — not an int offset. Out-of-range row/col clamp.
- **Every explicit jump ends in `EditorTab.RevealLines`** (#200): go to line, go to symbol, Back/Forward, a Find result, `tuicode file.cs:42` and `Enter` in a diff. It scrolls so the range's first line sits `Reveal.Margin` (2) rows below the top, or below the sticky lines, or not at all when the range is already in view with that margin at both ends — so a hop to something you can see doesn't move the screen. `Reveal` (TG-free, in `TuiCode.Editor`) is the whole of the calculation and owns the margin the diff pane reads too. It runs *after* the cursor move, since TG's `ScrollToMakeCursorVisible` would otherwise have the last word, and does nothing before the first layout (height 0). Ordinary movement — arrows, PgUp/PgDn, find-next, next-occurrence — keeps TG's minimal scrolling. The gutter follows for free, off `ViewportChanged`.
- **A jump never carries a selection** (#360): `EditorTab.MoveCursor` drops extra cursors and the selection before it moves, and `Select` (Find results) starts from no selection, so the match is all that's selected.
- Cursor location history (#35) is `CursorLocationHistory` (`Navigation/`) — a pure, TG-free back/forward stack, unit-tested directly like `MnemonicResolver`. `WorkbenchHost` feeds it: within-file moves arrive via `EditorGroup.CursorMoved` (re-raised from `CodeEditor.CursorMoved`, in file coordinates), file switches via `ActiveTabChanged`. The history's own heuristic decides what counts as a navigable **jump** vs. **drift**: a jump is a different file, a row delta ≥ threshold (default 10), or an explicit jump (Go-to-line passes `explicitJump: true` so even a short hop records). Drift (small same-file moves) just updates the current entry so a later jump's Back target is where the user actually was — typing and single-arrow steps never fill the stack. A jump truncates the forward/redo path (browser semantics). Driving the cursor on Back/Forward/Go-to-line sets `_suppressHistory` so those programmatic moves aren't re-recorded. No key interception is needed (the event is editor-scoped), so the explorer/settings/TreeView are untouched — the two concerns #35 flagged both dissolve.
Expand All @@ -447,6 +447,7 @@ What follows is how TuiCode implements it today.
- **Follow link** (`fl`, no default key, `Editor` scope, #466) is enabled only while the cursor is on a link (`Links.At`, pure): `http`, `https` or `mailto`, trailing punctuation and unbalanced closing brackets trimmed, and a Markdown `[text](url)` from anywhere in it. `SystemBrowser` launches `open`/`xdg-open` with its output redirected so it can't write over the screen, or the shell on Windows. Over SSH (`IEnvironment.IsOverSsh`) it copies the link instead, through the editor's own copy, so `Ctrl+V` pastes it. Tests swap `WorkbenchHost.Browser` for a fake.
- The status bar's `Ln X, Col Y` (right-aligned, #120) is polled on `IApplication.Iteration` (`Workbench.ShowCursorPosition`), not driven by `CursorMoved`: that comes from keys and the mouse only, so programmatic moves (find, Go-to-line, multi-caret, line moves) go unreported. `StatusBarPart.SetPosition` no-ops when unchanged, so polling costs a tuple compare. Col counts the model's grapheme cells, so a tab is one column.
- **Go to definition** (`Ctrl+G D`, `gd`, `Editor` scope, #454) asks the file's language server (see *Language servers*). One answer jumps there the way Go to line does, recorded as an explicit jump; several (a partial class) open `DefinitionPickerView`, the shape of Go to symbol's; none is a status-bar line. Places that aren't files on disk (metadata) are dropped. Only the latest request jumps, so a slow answer to an earlier press is ignored. The command stays enabled without a ready server, so the key and the palette can say what's missing; only the menu dims it until the server is ready (`IsAvailableFrom`), the one exception to the menu following the palette (`MenuBarHostTests`).
- **Quick info** (`Ctrl+G I`, `gi`, `Editor` scope, #591) asks the server for `textDocument/hover` and shows the answer in `QuickInfoView`, a box under the caret's line, or above it when there's more room there (`QuickInfo.Place`), at most `QuickInfo.MaxHeight` rows and never wider than the text. `LspHover` reads every shape the protocol allows: fenced code is the signature, coloured by the fence's grammar or else the file's (`CodeBlockColours`, shared with the chat), and the rest is prose, wrapped at `QuickInfo.ProseWidth` unless the code is wider, with markdown escapes, inline-code backticks and rules taken out. The box never takes focus: its keys are a `LayeredScope` over whatever had them, so `Esc` closes it, `Enter` closes it and runs Go to definition, the arrows and page keys scroll it, and any other key closes it and goes on to the editor; `F1` leaves it open and lists them (`QuickInfoKeys`). Its buttons sit on its bottom border and lose their labels, down to just the keys, when the box is too narrow for them (`FitButtons`). A click, a scroll or a resize that moves the caret's cell closes it on the next iteration, once its scope is on top again. Its rules otherwise follow `gd`'s: only the latest request shows, it's dimmed in the menu until the server is ready, and it says the same thing without one; an empty answer is `Nothing to show here`.
- **Find usages** (`Ctrl+G U`, `gu`, `Editor` scope, #455) asks the server for `textDocument/references` without the declaration, and lists them in the sidebar's **Usages** tab (`UsagesView`), grouped by file by the pure `UsageTree`: files in path order, each usage its line number and trimmed text, cut with an ellipsis as it's drawn. Its header names the symbol after its type (`Usages of LineDiff.Hunks`), from the definition's `textDocument/documentSymbol` (`LspSymbols`), falling back to the word under the caret. Only the latest request fills the tab, a Find search never touches it, and `Enter` jumps as Go to definition does. `Ctrl+Shift+U` (`fu`, Focus usages) goes back to them without asking again. It's dimmed in the menu like `gd`, and says the same thing without a server.
- macOS gotcha: by default Mission Control's "Move left/right a space" eats `Ctrl+Left`/`Ctrl+Right` before iTerm2 sees them. Disable in System Settings → Keyboard → Keyboard Shortcuts → Mission Control.

Expand Down
7 changes: 7 additions & 0 deletions src/TuiCode.Abstractions/CommandIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ public static class CommandIds
public const string SymbolPickerPageDown = "symbolPicker.action.pageDown";

public const string GoToDefinition = "workbench.action.goToDefinition";
public const string QuickInfo = "workbench.action.quickInfo";
public const string QuickInfoClose = "quickInfo.action.close";
public const string QuickInfoGoToDefinition = "quickInfo.action.goToDefinition";
public const string QuickInfoUp = "quickInfo.action.up";
public const string QuickInfoDown = "quickInfo.action.down";
public const string QuickInfoPageUp = "quickInfo.action.pageUp";
public const string QuickInfoPageDown = "quickInfo.action.pageDown";
public const string FindUsages = "workbench.action.findUsages";
public const string AiAssistance = "workbench.action.aiAssistance";
public const string AssistantSend = "assistant.action.send";
Expand Down
1 change: 1 addition & 0 deletions src/TuiCode.Abstractions/CommandMnemonics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ private static IReadOnlyDictionary<string, string> Build()
[CommandIds.GoToSymbol] = "gs",
[CommandIds.GoToMatchingBracket] = "gm",
[CommandIds.GoToDefinition] = "gd",
[CommandIds.QuickInfo] = "gi",
[CommandIds.FindUsages] = "gu",
[CommandIds.AiAssistance] = "ai",
[CommandIds.NavigateBack] = "gp",
Expand Down
3 changes: 3 additions & 0 deletions src/TuiCode.Abstractions/IInputScopeStack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public interface IInputScopeStack
/// </summary>
void Pop(IKeybindingService scope);

/// <summary>The scope receiving keys, or null while the stack is empty.</summary>
IKeybindingService? Top { get; }

/// <summary>Delegate handling to the top-of-stack scope.</summary>
KeyHandlingResult Handle(Key key);
}
3 changes: 3 additions & 0 deletions src/TuiCode.Editor/CodeEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,9 @@ private int ColumnsBefore(string[] line, int column)
return columns;
}

/// <summary>The primary caret's screen cell, or null while it's out of view.</summary>
public Point? CaretOnScreen => ViewportPosition(new Caret(InsertionPoint)) is { } point ? ViewportToScreen(point) : null;

// Editor places the cursor only in its own draw, which this replaces.
private void PlaceCursor()
{
Expand Down
6 changes: 6 additions & 0 deletions src/TuiCode.Editor/EditorTab.cs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,12 @@ public void MoveCursorToCharacter(int row, int character)
/// <summary>A screen row while wrapped.</summary>
public int TopRow => _text.Viewport.Y;

/// <summary>The primary caret's screen cell, or null while it's out of view.</summary>
public System.Drawing.Point? CaretOnScreen => _text.CaretOnScreen;

/// <summary>Where the text shows on screen, less the gutter and scroll bars.</summary>
public System.Drawing.Rectangle TextOnScreen => _text.ViewportToScreen(new System.Drawing.Rectangle(System.Drawing.Point.Empty, _text.Viewport.Size));

public int VisibleRows => _text.Viewport.Height;

/// <summary>
Expand Down
Loading
Loading