Skip to content

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

Merged
samkeen merged 2 commits into
mainfrom
claude/nested-list-tab-behavior-boxiy0
Aug 2, 2026
Merged

ui: Tab nests a list item, ⇧Tab lifts it back out#130
samkeen merged 2 commits into
mainfrom
claude/nested-list-tab-behavior-boxiy0

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 Markdown list indentation with Tab and outdentation with Shift+Tab.
    • Supports nested lists, ordered-list renumbering, bullet preservation, and multi-item selections.
    • Tab retains its existing behavior outside list contexts.
    • Added keyboard shortcut guidance for list indentation and outdentation.
  • Documentation

    • Clarified editor keyboard handling and list indentation behavior.

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
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@samkeen, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aef6dc94-9d08-4d6a-8a9e-df18e35a1107

📥 Commits

Reviewing files that changed from the base of the PR and between a2255a3 and 6209b44.

📒 Files selected for processing (2)
  • ui/src/list.test.ts
  • ui/src/list.ts
📝 Walkthrough

Walkthrough

The editor now supports Markdown list nesting with Tab and lifting with Shift-Tab. A pure list-editing engine parses list structure, generates text and selection changes, preserves numbering and markers, and integrates with editor bindings, keymap validation, shortcuts, and tests.

Changes

List indentation editing

Layer / File(s) Summary
Markdown list parsing and structure
ui/src/list.ts
The new engine defines edit contracts and parses Markdown list markers, indentation, blocks, siblings, parents, and subtrees.
Indentation edits and selection mapping
ui/src/list.ts
The engine generates nesting and lifting edits, updates whitespace and ordered markers, maps selections, and applies changes.
Editor commands and key bindings
ui/src/main.ts, ui/src/bindings.ts, ui/src/editorkeys.ts, ui/src/shortcuts.ts
Tab and Shift-Tab invoke list commands. The editor updates document state, selection, and scrolling. Markdown keymaps participate in chord checks.
List and keymap behavior tests
ui/src/list.test.ts, ui/src/editorkeys.test.ts
Tests cover list movement, numbering, markers, whitespace, selections, command bindings, and competing CodeMirror keymaps.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant BindingRegistry
  participant runListShift
  participant ListEngine
  participant EditorState
  User->>BindingRegistry: press Tab or Shift-Tab
  BindingRegistry->>runListShift: invoke configured list command
  runListShift->>ListEngine: indentList or outdentList with selection
  ListEngine-->>runListShift: return ListEdit or inert result
  runListShift->>EditorState: apply changes and update selection and scroll
Loading

Possibly related PRs

  • AlteredCraft/B2#118: Introduces the keyboard registry and CodeMirror keymap validation extended by this change.
  • AlteredCraft/B2#125: Establishes the binding registry extended with list indentation commands.

Suggested reviewers: claude

Poem

A rabbit taps Tab in a markdown maze,
And lifts with Shift-Tab through branching ways.
Bullets stay bright, numbers fall in line,
Carets hop softly to positions fine.
The editor blooms with a burrowed design.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/nested-list-tab-behavior-boxiy0

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.

@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: 1

🤖 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/list.ts`:
- Around line 252-256: Update Marker to retain the list marker identity, then in
scan assign kind from the bullet character or delimiter using bullet ?? delim.
Apply the same marker-based kind comparison in the downward walk alongside
ordered() so runs stop when ordered delimiters or bullet characters change.
🪄 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: d40d07d9-ec90-43f9-a6a9-65e5bf216235

📥 Commits

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

📒 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/list.ts Outdated
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
@samkeen
samkeen merged commit c519e72 into main Aug 2, 2026
2 checks passed
@samkeen
samkeen deleted the claude/nested-list-tab-behavior-boxiy0 branch August 2, 2026 05:50
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