Skip to content

ui: Tab nests a list item, ⇧Tab lifts it back out - #131

Merged
samkeen merged 3 commits into
mainfrom
claude/pr-130-review-amqp05
Aug 2, 2026
Merged

ui: Tab nests a list item, ⇧Tab lifts it back out#131
samkeen merged 3 commits into
mainfrom
claude/pr-130-review-amqp05

Conversation

@samkeen

@samkeen samkeen commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Nesting a list was the one structural edit the editor had no gesture for.
Enter continues a marker and ⌘B wraps a word, but making - b a child of
- a meant counting spaces by hand — and the key everyone reaches for first,
Tab, was unbound, so it did what an unbound Tab does in a webview: walked the
focus ring straight out of the buffer and into the next pane.

Tab is claimed only with the caret in a list item. Everywhere else in the
note indentList returns null, the binding declines, and Tab goes on stepping
the focus ring — which is why this isn't CodeMirror's own indentWithTab, a
one-liner that takes the key outright and takes the keyboard's way out of the
editor with it. Indenting a paragraph would make a code block anyway, which
nobody means by Tab. Inside a list the key is swallowed even when nothing can
move (the first item of a list has nothing to nest under): a gesture that
sometimes ejects you from the buffer is worse than one that sometimes does
nothing.

  • ui/src/list.ts — the engine, pure and node-testable (the format.ts pattern).
    It edits leading whitespace and the digits of an ordered marker, and nothing
    else. The new indent is the previous sibling's content column, not a
    fixed two spaces, because that is where CommonMark puts a child — indent
    under 1. a by two and the nesting simply doesn't parse. An item's subtree
    travels with it, or ⇧Tab would re-parent the children onto whatever the item
    landed beside. Ordered runs are renumbered, both the one an item left and
    the one it joined: 2. nested out of 1. 2. 3. is a list whose first item
    says "2.", and renders as "2.". The start number stays the author's where
    they chose it (a list opening at 5. goes on opening at 5.), and the lazy
    1. 1. 1. style is left alone — it renders identically and nothing moved
    into or out of it.
  • main.ts — runListShift, the CodeMirror half. A caret inside code declines
    before the engine is asked: a - item line in a fence is text, not
    structure, and inCodeContext is the same read the rich paste makes.
  • bindings.ts / shortcuts.ts — declared once, with a row in the sheet, so the
    chords are rebindable and findable like every other (obligation 4).

editorkeys.ts gains markdownKeymap in STOCK_KEYMAPS, which was a real gap:
markdown() installs it at Prec.high, above B2's own chords, and nothing
compared it against them. Its ⏎ and ⌫ overlap nothing, but "CodeMirror leaves
Tab alone" is only an assertion if every keymap the editor installs is in that
list — so editorkeys.test.ts now asserts it directly, and would catch an
indentWithTab arriving in defaultKeymap as much as a markdown binding.

list.test.ts covers the gesture in 29 checks, asserting on the Markdown that
comes out rather than the change list — including the two shapes that read as
bugs when they're wrong: the ordered renumbering, and Tab declining outside a
list.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_015oc7gyoL7AccTEF2picayR

Summary by CodeRabbit

  • New Features

    • Added Tab and Shift+Tab support for indenting and outdenting Markdown lists.
    • Supports nested items, multi-item selections, ordered lists, bullets, continuation lines, and blank lines while preserving formatting.
    • Tab behavior remains available for non-list content and is disabled inside code blocks.
  • Documentation

    • Updated editor shortcut documentation to describe list indentation and outdentation behavior.

claude added 3 commits August 2, 2026 03:45
Nesting a list was the one structural edit the editor had no gesture for.
Enter continues a marker and ⌘B wraps a word, but making `- b` a child of
`- a` meant counting spaces by hand — and the key everyone reaches for first,
Tab, was unbound, so it did what an unbound Tab does in a webview: walked the
focus ring straight out of the buffer and into the next pane.

Tab is claimed **only with the caret in a list item**. Everywhere else in the
note `indentList` returns null, the binding declines, and Tab goes on stepping
the focus ring — which is why this isn't CodeMirror's own `indentWithTab`, a
one-liner that takes the key outright and takes the keyboard's way out of the
editor with it. Indenting a paragraph would make a code block anyway, which
nobody means by Tab. Inside a list the key is swallowed even when nothing can
move (the first item of a list has nothing to nest under): a gesture that
*sometimes* ejects you from the buffer is worse than one that sometimes does
nothing.

- ui/src/list.ts — the engine, pure and node-testable (the format.ts pattern).
  It edits leading whitespace and the digits of an ordered marker, and nothing
  else. The new indent is the previous sibling's **content column**, not a
  fixed two spaces, because that is where CommonMark puts a child — indent
  under `1. a` by two and the nesting simply doesn't parse. An item's subtree
  travels with it, or ⇧Tab would re-parent the children onto whatever the item
  landed beside. Ordered runs are renumbered, both the one an item left and
  the one it joined: `2.` nested out of `1. 2. 3.` is a list whose first item
  says "2.", and renders as "2.". The start number stays the author's where
  they chose it (a list opening at `5.` goes on opening at `5.`), and the lazy
  `1. 1. 1.` style is left alone — it renders identically and nothing moved
  into or out of it.
- main.ts — `runListShift`, the CodeMirror half. A caret inside code declines
  before the engine is asked: a `- item` line in a fence is text, not
  structure, and `inCodeContext` is the same read the rich paste makes.
- bindings.ts / shortcuts.ts — declared once, with a row in the sheet, so the
  chords are rebindable and findable like every other (obligation 4).

editorkeys.ts gains `markdownKeymap` in STOCK_KEYMAPS, which was a real gap:
`markdown()` installs it at Prec.high, *above* B2's own chords, and nothing
compared it against them. Its ⏎ and ⌫ overlap nothing, but "CodeMirror leaves
Tab alone" is only an assertion if every keymap the editor installs is in that
list — so editorkeys.test.ts now asserts it directly, and would catch an
`indentWithTab` arriving in `defaultKeymap` as much as a markdown binding.

list.test.ts covers the gesture in 29 checks, asserting on the Markdown that
comes out rather than the change list — including the two shapes that read as
bugs when they're wrong: the ordered renumbering, and Tab declining outside a
list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oc7gyoL7AccTEF2picayR
Review catch on #130. `groupOf` collected a renumbering run by asking only
"ordered or bullet?", but CommonMark starts a new list at every change of
*marker* — `1.` then `2)`, or `-` then `*` — so a run could span two lists and
the renumbering would walk straight across the boundary.

Two failures, one boundary:

    1. a          1. a
    1) x    ->    2) x     <- rewritten; `1) x` heads its own list
    2) y          3) y
    3) z             1) z

    1. a          1. a
    5) x    ->    2) x     <- the author's start number, lost
    6) y             1) y

The second is why the fix isn't only in `groupOf`. `renumber` decides a run's
start number by asking whether its lead item *was* the head of its run, and
that question has to be about the list as well: `5)` under a `1.` item is a
head — its own — and reading the line above as a sibling restarts it at 1.

So `Marker` keeps the marker's identity (`kind`), and the two sibling walks
gain run-scoped twins that stop at a change of it. `prevSibling` itself is left
alone on purpose: "which item am I nested under?" is a question about columns,
and `* b` landing under `- a` is the shape the author asked for by pressing Tab
(pinned by "the bullet character is the author's"). "Which items share my
numbering?" is a question about the list. The two answers part company exactly
here, which is why they are now two functions.

The bullet half of the boundary was already inert — a run containing a bullet
has no numbers, so `renumber` returns before it can do anything — but the walks
read the same either way and the comment above them claimed the rule already.

list.test.ts pins both documents above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oc7gyoL7AccTEF2picayR
The gesture's contract — a caret in a list never hands Tab back to the
focus ring — held only on marker lines. Three places the caret is
visibly in a list still ejected it from the buffer:

- A continuation line, an item's own wrapped text. `owningItem` now
  attributes it to the item it belongs to, and the gesture moves that
  item, subtree and all. A lazy column-0 line reaches its owner through
  adjacency only: past a blank it is a new paragraph (CommonMark), and
  a column-0 block starter — a heading, a rule, a fence, a quote —
  interrupts a paragraph rather than continuing one, so neither claims.

- The blank interior to a loose list: claimed and swallowed. Interior
  means list material directly on both sides with a real item above it
  in the block — a trailing blank, either blank of a two-blank gap, and
  the line above a list are document space, and Tab moves on.

- A blockquoted list. `> - a` is a bullet behind a container prefix the
  scanner doesn't parse, so the engine still says null — and main.ts's
  new `inListItem` gives the syntax tree the last word before the key
  goes back to the focus ring: claimed-but-inert, the caret being
  visibly on a bullet. The contained-list edit itself stays unbuilt;
  the contract holds anyway.

list.test.ts covers each: the continuation gesture in both directions,
the lazy line, the paragraph and the block starter that are not
continuations, the interior blank, the three kinds of document space,
and the blockquote null with a comment naming whose case it now is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LWAKbV3HFnjbgejJU6tGB
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds CommonMark-aware list indentation and outdentation. It connects Tab and Shift-Tab to CodeMirror, preserves editor focus when appropriate, validates keymap conflicts, and documents the new shortcuts.

Changes

Markdown list editing

Layer / File(s) Summary
List edit engine and tests
ui/src/list.ts, ui/src/list.test.ts
The pure list engine parses Markdown structure, moves list subtrees, preserves markers, renumbers ordered lists, remaps selections, and validates indentation cases.
Editor keybinding integration
ui/src/main.ts, ui/src/bindings.ts
The editor registers configured list commands, applies list edits, declines code contexts, and claims list gestures when required.
Keymap validation and documentation
ui/src/editorkeys.ts, ui/src/editorkeys.test.ts, ui/src/shortcuts.ts
Markdown keymaps participate in overlap analysis. Tests validate the bindings and Tab exclusivity. Shortcut documentation describes list nesting and lifting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CodeMirror
  participant EditorKeymap
  participant ListEngine
  CodeMirror->>EditorKeymap: receive Tab or Shift-Tab
  EditorKeymap->>ListEngine: invoke indentList or outdentList
  ListEngine-->>EditorKeymap: return ListEdit or null
  EditorKeymap-->>CodeMirror: apply document changes and selection
Loading

Possibly related PRs

  • AlteredCraft/B2#130: Implements related list indentation and outdentation behavior.
  • AlteredCraft/B2#118: Introduces the shared keyboard registry and CodeMirror keymap validation infrastructure.
  • AlteredCraft/B2#123: Modifies related editor keyboard bindings and Tab conflict handling.

Suggested reviewers: claude

Poem

A rabbit pressed Tab in a Markdown field,
And watched a list move where structure willed.
Shift-Tab brought branches back in line,
While ordered numbers stayed aligned.
CodeMirror checked each chord with care,
And focus remained safely there.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the primary change: Tab nests list items and Shift+Tab lifts them out.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/pr-130-review-amqp05

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@samkeen
samkeen merged commit a439043 into main Aug 2, 2026
1 of 2 checks passed
@samkeen
samkeen deleted the claude/pr-130-review-amqp05 branch August 2, 2026 05:51

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
ui/src/list.test.ts (1)

16-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Give assert an assertion signature to drop the redundant fallbacks.

assert returns void, so TypeScript does not narrow the checked value. That forces there?.changes ?? [] and there?.selFrom ?? 0 at lines 183-186 even though line 182 already proved there !== null. The fallbacks hide a real failure as a passing round trip if the narrowing assumption ever breaks.

An asserts cond signature narrows at the call site.

♻️ Proposed signature change
-function assert(cond: boolean, msg: string): void {
+function assert(cond: boolean, msg: string): asserts cond {
   if (!cond) throw new Error(`assertion failed: ${msg}`);
 }

The call sites then simplify, for example:

   const there = indentList(start, 6, 6);
   assert(there !== null, "indent applied");
-  const mid = applyChanges(start, there?.changes ?? []);
-  const back = outdentList(mid, there?.selFrom ?? 0, there?.selTo ?? 0);
+  const mid = applyChanges(start, there.changes);
+  const back = outdentList(mid, there.selFrom, there.selTo);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/list.test.ts` around lines 16 - 18, Update the assert function’s
return type to an assertion signature, `asserts cond`, so successful calls
narrow the checked condition at call sites. Then remove the redundant
optional-chaining and fallback defaults in the round-trip assertions using
there, while preserving the existing null check and direct property access.
ui/src/editorkeys.test.ts (1)

181-188: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive installed from the editor scope instead of restating it.

This list repeats the editor-scope ids from lines 148-154 with editor.save removed. Adding a future editor binding requires updating both lists. If only the first is updated, this check silently stops covering the new chord, and the failure mode is a chord that stops working with no test failure.

Derive the list from DEFAULT_BINDINGS and exclude the one id the document handler owns.

♻️ Proposed derivation
-  const installed = [
-    "format.bold",
-    "format.italic",
-    "editor.table",
-    "editor.paste-plain",
-    "editor.list.indent",
-    "editor.list.outdent",
-  ];
+  // Every editor-scope chord except ⌘S, which the document handler owns rather than
+  // CodeMirror — see the scope comment in bindings.ts.
+  const installed = DEFAULT_BINDINGS.filter(
+    (b) => b.scope === "editor" && b.id !== "editor.save",
+  ).map((b) => b.id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/editorkeys.test.ts` around lines 181 - 188, Update the installed-key
list in the relevant test to derive editor-scope IDs from DEFAULT_BINDINGS,
excluding the editor.save binding owned by the document handler, instead of
maintaining a duplicated literal list. Preserve the existing assertions against
the derived collection so newly added editor bindings are covered automatically.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ui/src/editorkeys.test.ts`:
- Around line 160-171: Update editorChords in editorkeys.ts to include
platform-specific b.win and b.linux bindings alongside b.mac and b.key, so the
Tab assertion detects stock bindings on every supported platform. Preserve the
existing chord normalization and ensure the editor.list.indent test’s guarantee
applies consistently across platforms.

In `@ui/src/list.ts`:
- Around line 106-147: Update scan to track fenced-code state while iterating
through document lines, toggling state for fence opener and closer lines before
item classification so both are excluded. When the state indicates the line is
inside a fence, bypass ITEM matching and emit a plain line entry, preventing
list markers within fenced content from becoming Marker objects.

In `@ui/src/main.ts`:
- Around line 2512-2524: Update the inCodeContext branch of runListShift so Tab
is consumed inside code fences instead of returning false and allowing focus to
leave the editor; return true there, or apply the editor’s standard indent unit,
while preserving the existing no-ejection behavior outside code context.

---

Nitpick comments:
In `@ui/src/editorkeys.test.ts`:
- Around line 181-188: Update the installed-key list in the relevant test to
derive editor-scope IDs from DEFAULT_BINDINGS, excluding the editor.save binding
owned by the document handler, instead of maintaining a duplicated literal list.
Preserve the existing assertions against the derived collection so newly added
editor bindings are covered automatically.

In `@ui/src/list.test.ts`:
- Around line 16-18: Update the assert function’s return type to an assertion
signature, `asserts cond`, so successful calls narrow the checked condition at
call sites. Then remove the redundant optional-chaining and fallback defaults in
the round-trip assertions using there, while preserving the existing null check
and direct property access.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c2d6eeb-d7b0-4d34-b3b3-7d1458ba38b2

📥 Commits

Reviewing files that changed from the base of the PR and between e08afbd and 7bc0ca3.

📒 Files selected for processing (7)
  • ui/src/bindings.ts
  • ui/src/editorkeys.test.ts
  • ui/src/editorkeys.ts
  • ui/src/list.test.ts
  • ui/src/list.ts
  • ui/src/main.ts
  • ui/src/shortcuts.ts

Comment thread ui/src/editorkeys.test.ts
Comment on lines +160 to +171
check("Tab reaches the editor's list commands — nothing in the editor binds it first", () => {
// The assumption `editor.list.indent` rests on, and the reason `markdownKeymap` is in
// STOCK_KEYMAPS now: `markdown()` installs it at Prec.high, *above* B2's own chords,
// so a release that gave it a Tab binding would take the key without a word. The stock
// list is also where `indentWithTab` would show up if it were ever added to
// `defaultKeymap` — that one would silently turn Tab back into plain indentation.
const onTab = editorChords().filter((c) => keystrokes(c.spec).some((f) => f.endsWith("Tab")));
assertEq(
onTab.map((c) => `${c.spec} — ${c.source} ${c.command}`),
[],
"stock chords over Tab",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The Tab guarantee misses platform-specific stock bindings.

editorChords in ui/src/editorkeys.ts line 80 reads b.mac ?? b.key. It never reads b.win or b.linux. A stock binding that declared Tab only under win or linux would not appear in onTab, and this check would still pass.

The comment at lines 161-165 states the assertion protects editor.list.indent from a future stock Tab binding. On Windows and Linux it does not fully do that.

Either widen editorChords to include b.win and b.linux, or narrow the comment to say the guarantee covers mac and the platform-neutral key only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/editorkeys.test.ts` around lines 160 - 171, Update editorChords in
editorkeys.ts to include platform-specific b.win and b.linux bindings alongside
b.mac and b.key, so the Tab assertion detects stock bindings on every supported
platform. Preserve the existing chord normalization and ensure the
editor.list.indent test’s guarantee applies consistently across platforms.

Comment thread ui/src/list.ts
Comment on lines +106 to +147
function scan(doc: string): Ln[] {
const out: Ln[] = [];
let from = 0;
for (const text of doc.split("\n")) {
const ws = leadingWs(text);
const blank = ws.length === text.length;
const indent = measure(ws);
const m = blank || RULE.test(text) ? null : ITEM.exec(text);
if (!m) {
out.push({ from, text, blank, indent });
} else {
// An unmatched group is undefined at runtime, which `RegExpExecArray`'s `string[]`
// index signature doesn't say — hence the explicit types rather than a destructure
// that would read as total.
const lead: string = m[1];
const bullet: string | undefined = m[2];
const digits: string | undefined = m[3];
const delim: string | undefined = m[4];
const gap: string | undefined = m[5];
const markerText = bullet ?? `${digits}${delim}`;
const afterMarker = measure(lead + markerText);
// CommonMark: content sits one column past the marker plus the gap — except that a
// gap of five or more spaces is code indentation, and an item with no content at
// all has no gap to measure. Both cases put the content column one past the marker.
const gapped = gap === undefined ? afterMarker + 1 : measure(lead + markerText + gap);
const item: Marker = {
indent,
indentLen: lead.length,
content: gapped - afterMarker > 4 ? afterMarker + 1 : gapped,
// The alternation matched one branch or the other, so one of these is a string.
kind: bullet ?? delim ?? "",
};
if (digits !== undefined) {
item.num = Number(digits);
item.numLen = digits.length;
}
out.push({ from, text, blank, indent, item });
}
from += text.length + 1;
}
return out;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

scan classifies fenced code content as list items.

scan reads every line independently. A line inside a fenced code block that starts with - or 1. becomes a Marker. The engine can then re-indent or renumber that line when it falls inside the moved subtree or a renumbered run. inCodeContext in ui/src/main.ts only guards the caret position, so it does not prevent this.

The header excludes blockquoted lists but says nothing about fences. Track the fence state in scan and skip item classification inside it.

🐛 Proposed fence tracking in `scan`
+/** An open or close fence: three or more backticks or tildes, up to three columns in. */
+const FENCE = /^ {0,3}(`{3,}|~{3,})/;
+
 /** Read the document as lines, each classified as a list item or not. */
 function scan(doc: string): Ln[] {
   const out: Ln[] = [];
   let from = 0;
+  let fence: string | null = null;
   for (const text of doc.split("\n")) {
     const ws = leadingWs(text);
     const blank = ws.length === text.length;
     const indent = measure(ws);
-    const m = blank || RULE.test(text) ? null : ITEM.exec(text);
+    const f = FENCE.exec(text)?.[1];
+    if (fence === null && f !== undefined) fence = f[0];
+    else if (fence !== null && f !== undefined && f[0] === fence) fence = null;
+    const m = fence !== null || blank || RULE.test(text) ? null : ITEM.exec(text);

Note: the closing fence is itself inside the fence, so the toggle order above keeps the opener and the closer out of item classification.

🧰 Tools
🪛 OpenGrep (1.26.0)

[ERROR] 113-113: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/list.ts` around lines 106 - 147, Update scan to track fenced-code
state while iterating through document lines, toggling state for fence opener
and closer lines before item classification so both are excluded. When the state
indicates the line is inside a fence, bypass ITEM matching and emit a plain line
entry, preventing list markers within fenced content from becoming Marker
objects.

Comment thread ui/src/main.ts
Comment on lines +2512 to +2524
* so the no-ejection contract holds there too. And a caret inside code declines before
* the engine is asked at all: a `- item` line in a fence is text, not structure, and
* `inCodeContext` is the same read the rich paste makes to keep its hands off code.
*
* One range rather than `changeByRange`: an indent moves every offset after it, so a
* second cursor's edit would be computed against a document the first has already
* shifted. Multi-cursor nesting is a gesture nobody makes; ⌘B's is one they do.
*/
function runListShift(
view: EditorView,
shift: (doc: string, from: number, to: number) => ListEdit | null,
): boolean {
if (inCodeContext(view.state)) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tab inside a code fence moves focus out of the editor.

Line 2524 returns false in a code context. editorkeys.test.ts now asserts that no stock CodeMirror keymap binds Tab, so nothing else claims the key and the browser default runs. The caret then leaves the buffer.

The doc comment explains why the engine must not run there. It does not explain why the key is released rather than swallowed. The no-ejection contract that lines 2507-2512 describe applies just as well inside a fence, where a code block is the one place a user is most likely to expect Tab to indent.

Confirm this is intended. If it is not, return true in the code context, or insert an indent unit there.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/main.ts` around lines 2512 - 2524, Update the inCodeContext branch of
runListShift so Tab is consumed inside code fences instead of returning false
and allowing focus to leave the editor; return true there, or apply the editor’s
standard indent unit, while preserving the existing no-ejection behavior outside
code context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants