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
4 changes: 1 addition & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4489,9 +4489,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
edit.start,
edit.end,
edit.replacement,
key === "Tab"
? { expandedCursorAfterReplace: selection.start + edit.replacement.length }
: undefined,
edit.cursor === undefined ? undefined : { expandedCursorAfterReplace: edit.cursor },
)
) {
return true;
Expand Down
53 changes: 51 additions & 2 deletions apps/web/src/composer-list-continuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,57 @@ describe("composer list continuation", () => {
expect(listContinuationForEnter(value, cursor)).toBeNull();
});

it("indents list items on Tab", () => {
expect(applyEdit("- foo", listIndentForTab("- foo", 2, 2)!)).toBe(" - foo");
it.each([
["1. a\n 1. ", "1. a\n2. "],
["1) a\n 1) x\n 2) ", "1) a\n 1) x\n2) "],
["- a\n 1. ", "- a\n- "],
["1. a\n - [ ] ", "1. a\n2. "],
["1. a\n 1. x\n 1. ", "1. a\n 1. x\n 2. "],
])("leaves one level of nesting on an empty nested item in %j", (value, expected) => {
expect(applyEdit(value, listContinuationForEnter(value, value.length)!)).toBe(expected);
});

it("exits the list on an empty item indented with no parent", () => {
expect(applyEdit(" - ", listContinuationForEnter(" - ", 4)!)).toBe("");
});
});

describe("listIndentForTab", () => {
function tab(value: string, cursor = value.length) {
const edit = listIndentForTab(value, cursor, cursor);
return edit && { value: applyEdit(value, edit), cursor: edit.cursor };
}

it.each([
// Children nest at the item's content column, so Markdown reads them as nested.
["1. a\n2. ", "1. a\n 1. "],
["1. a\n2. b", "1. a\n 1. b"],
["10) a\n11) ", "10) a\n 1) "],
["1. a\n 1. x\n2. ", "1. a\n 1. x\n 2. "],
["1. a\n 1. x\n 1. y\n2. ", "1. a\n 1. x\n 1. y\n 2. "],
["1. a\n - x\n2. ", "1. a\n - x\n 1. "],
["1. a\n 1. x\n2. ", "1. a\n 1. x\n 2. "],
["- a\n- ", "- a\n - "],
["- [ ] a\n- [ ] ", "- [ ] a\n - [ ] "],
["- a\n1. ", "- a\n 1. "],
])("nests the last item of %j", (value, expected) => {
const result = tab(value);
expect(result?.value).toBe(expected);
expect(result?.cursor).toBe(expected.length);
});

it("keeps the caret on the item text", () => {
const value = "1. a\n2. bc";
expect(tab(value, value.length - 1)).toEqual({ value: "1. a\n 1. bc", cursor: 12 });
});

it("indents by two spaces with no item to nest under", () => {
expect(tab("- foo", 2)).toEqual({ value: " - foo", cursor: 4 });
expect(tab("text\n1. ")).toEqual({ value: "text\n 1. ", cursor: 10 });
expect(tab("1. a\n 1. ")).toEqual({ value: "1. a\n 1. ", cursor: 13 });
});

it("ignores non-list lines and ranged selections", () => {
expect(listIndentForTab("plain", 2, 2)).toBeNull();
expect(listIndentForTab("- foo", 1, 3)).toBeNull();
});
Expand Down
154 changes: 137 additions & 17 deletions apps/web/src/composer-list-continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
*
* Implemented once at the ChatComposer level (store replacement), so both
* composer modes behave identically and serialize identically:
* Shift+Enter on a list item continues it, Enter on an empty item exits the
* list, and Tab indents the item. Plain Markdown markers only — no real list
* nodes anywhere.
* Shift+Enter on a list item continues it, Enter on an empty item leaves one
* level of nesting (or the list), and Tab nests the item under the one above.
* Plain Markdown markers only — no real list nodes anywhere.
*/

export interface ComposerListEdit {
/** Expanded (plain string) offsets into the prompt. */
start: number;
end: number;
replacement: string;
/** Expanded caret after the edit; defaults to the end of the replacement. */
cursor?: number;
}

type ListMarker =
Expand Down Expand Up @@ -98,17 +100,63 @@ function isInsideInlineToken(value: string, cursor: number): boolean {
return false;
}

function currentLine(value: string, cursor: number): { start: number; end: number; text: string } {
interface Line {
start: number;
end: number;
text: string;
}

function currentLine(value: string, cursor: number): Line {
const start = value.lastIndexOf("\n", cursor - 1) + 1;
const endIndex = value.indexOf("\n", cursor);
const end = endIndex === -1 ? value.length : endIndex;
return { start, end, text: value.slice(start, end) };
}

/** The list lines directly above `line`, nearest first, up to the first non-list line. */
function* listLinesAbove(value: string, line: Line): Generator<ListMarker> {
let start = line.start;
while (start > 0) {
const previous = currentLine(value, start - 1);
const parsed = parseListMarker(previous.text);
if (!parsed) return;
yield parsed.marker;
start = previous.start;
}
}

function isDeeper(indent: string, than: string): boolean {
return indent.length > than.length && indent.startsWith(than);
}

/**
* How far a child must be indented to nest under this item: its content
* column, as CommonMark counts it. Two spaces under `1. ` would read as a
* sibling, so the agent would get a flat list.
*/
function childIndentWidth(marker: ListMarker): number {
// A task's box is item content, so its children nest under the dash.
if (marker.kind === "task") return 2;
const markerText =
marker.kind === "ordered" ? `${marker.numberText}${marker.delimiter}` : marker.bullet;
const space = marker.space.length;
return markerText.length + (space >= 1 && space <= 4 ? space : 1);
}

/** The nearest item above at a shallower indent: the one `indent` nests under. */
function parentMarker(value: string, line: Line, indent: string): ListMarker | null {
if (indent === "") return null;
for (const marker of listLinesAbove(value, line)) {
if (isDeeper(indent, marker.indent)) return marker;
}
return null;
}

/**
* Enter on a list item line: continue the list, or exit it when the item is
* empty. Returns null for non-list lines, carets inside the marker, and
* carets inside an inline chip — all fall through to a plain newline.
* Enter on a list item line: continue the list, or, on an empty item, leave
* one level of nesting or the list itself. Returns null for non-list lines,
* carets inside the marker, and carets inside an inline chip — all fall
* through to a plain newline.
*/
export function listContinuationForEnter(value: string, cursor: number): ComposerListEdit | null {
if (!Number.isInteger(cursor) || cursor < 0 || cursor > value.length) return null;
Expand All @@ -119,8 +167,14 @@ export function listContinuationForEnter(value: string, cursor: number): Compose
if (cursor < markerEnd) return null;
if (isInsideInlineToken(value, cursor)) return null;
if (value.slice(markerEnd, line.end).trim() === "") {
// Empty item: Enter exits the list by removing the marker.
return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" };
// Empty item: a nested one moves up a level and continues its parent's
// list; a top-level one exits the list by removing the marker.
const parent = parentMarker(value, line, parsed.marker.indent);
return {
start: line.start,
end: Math.max(cursor, markerEnd),
replacement: parent ? nextMarkerText(parent) : "",
};
}
return {
start: cursor,
Expand All @@ -129,19 +183,85 @@ export function listContinuationForEnter(value: string, cursor: number): Compose
};
}

interface ListPrefix {
indent: string;
numberText?: string;
}

/** The list item line at a collapsed caret, unless the caret splits an inline chip. */
function listLineAt(
value: string,
start: number,
end: number,
): { line: Line; marker: ListMarker } | null {
if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null;
const line = currentLine(value, start);
const parsed = parseListMarker(line.text);
if (!parsed || isInsideInlineToken(value, start)) return null;
return { line, marker: parsed.marker };
}

/** Rewrites the item's indent and, for ordered items, its number, keeping the caret on the text. */
function rewritePrefix(
line: Line,
marker: ListMarker,
prefix: ListPrefix,
cursor: number,
): ComposerListEdit {
const editEnd =
line.start + marker.indent.length + (marker.kind === "ordered" ? marker.numberText.length : 0);
const replacement = `${prefix.indent}${marker.kind === "ordered" ? (prefix.numberText ?? "1") : ""}`;
const shift = replacement.length - (editEnd - line.start);
return {
start: line.start,
end: editEnd,
replacement,
cursor: cursor >= editEnd ? cursor + shift : line.start + replacement.length,
};
}

/**
* Tab on a list item line: indent by two spaces. Ranged selections, non-list
* lines, and carets inside an inline chip fall through (Shift+Tab stays the
* plan-mode toggle and is handled before this is consulted).
* Tab on a list item line: nest it under the item above at the same indent,
* written at that item's content column. A nested ordered item continues the
* numbering of the sublist it joins, or starts it at 1. With no item to nest
* under it indents by two spaces. Ranged selections, non-list lines, and
* carets inside an inline chip fall through (Shift+Tab stays the plan-mode
* toggle and is handled before this is consulted).
*/
export function listIndentForTab(
value: string,
start: number,
end: number,
): ComposerListEdit | null {
if (!Number.isInteger(start) || start !== end || start < 0 || start > value.length) return null;
const line = currentLine(value, start);
if (!parseListMarker(line.text)) return null;
if (isInsideInlineToken(value, start)) return null;
return { start: line.start, end: line.start, replacement: " " };
const at = listLineAt(value, start, end);
if (!at) return null;
const nested = nestedPrefix(value, at.line, at.marker);
if (!nested) {
return { start: at.line.start, end: at.line.start, replacement: " ", cursor: start + 2 };
}
return rewritePrefix(at.line, at.marker, nested, start);
}

function nestedPrefix(value: string, line: Line, marker: ListMarker): ListPrefix | null {
// Items already nested under the new parent, nearest first.
const children: ListMarker[] = [];
for (const above of listLinesAbove(value, line)) {
if (isDeeper(above.indent, marker.indent)) {
children.push(above);
continue;
}
if (above.indent !== marker.indent) return null;
// Join the sublist the item above already has, or start one at its content column.
const sublistDepth = Math.min(...children.map((child) => child.indent.length));
const lastChild = children.find((child) => child.indent.length === sublistDepth);
const indent = lastChild?.indent ?? `${marker.indent}${" ".repeat(childIndentWidth(above))}`;
if (marker.kind !== "ordered") return { indent };
const sibling =
lastChild?.kind === "ordered" && lastChild.delimiter === marker.delimiter ? lastChild : null;
const numberText = sibling
? nextOrderedMarkerText(`${sibling.numberText}${sibling.delimiter}`).slice(0, -1)
: "1";
return { indent, numberText };
}
return null;
}
3 changes: 2 additions & 1 deletion docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ block. What you typed is what the agent receives, markers and numbering
included, and `#1234` without a space still looks up a pull request.

Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter
on an empty line leaves it; **Tab** nests a list item. In a code block, Enter
on an empty line leaves it, one level of nesting at a time; **Tab** nests a
list item, numbering a nested ordered list from 1. In a code block, Enter
starts a new line at the current indentation, **Tab** and **Shift+Tab** indent
the selected lines, and a closing ` ``` ` followed by Enter, or two blank lines
at the end, leave the block. **Backspace** at the start of a code block turns it
Expand Down
Loading