Skip to content
Open
11 changes: 3 additions & 8 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4191,14 +4191,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: listIndentForTab(snapshot.value, selection.start, selection.end);
if (
edit &&
applyPromptReplacement(
edit.start,
edit.end,
edit.replacement,
key === "Tab"
? { expandedCursorAfterReplace: selection.start + edit.replacement.length }
: undefined,
)
applyPromptReplacement(edit.start, edit.end, edit.replacement, {
expandedCursorAfterReplace: edit.cursorAfter,
})
) {
return true;
}
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/composer-list-continuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,58 @@ describe("nextOrderedMarkerText", () => {
expect(nextOrderedMarkerText(marker)).toBe(expected);
});
});

describe("composer list renumbering", () => {
const enterAt = (value: string, cursor: number) => {
const edit = listContinuationForEnter(value, cursor)!;
return applyEdit(value, edit);
};

it("renumbers following items when an item is inserted mid-list", () => {
const value = "1. one\n2. two\n3. three";
// Enter at the start of "two" text pushes it down to a new item 3.
expect(enterAt(value, value.indexOf("two"))).toBe("1. one\n2. \n3. two\n4. three");
expect(enterAt(value, 6)).toBe("1. one\n2. \n3. two\n4. three");
});

it("puts the caret after the new marker when renumbering", () => {
const value = "1. one\n2. two";
const edit = listContinuationForEnter(value, 6)!;
expect(edit.cursorAfter).toBe(6 + "\n2. ".length);
});

it("leaves items that are already in sequence and other content alone", () => {
expect(enterAt("1. a\n\n5. b", 4)).toBe("1. a\n2. \n\n5. b");
expect(enterAt("1. a\ntext\n2. b", 4)).toBe("1. a\n2. \ntext\n2. b");
expect(enterAt("1. a\n1) b", 4)).toBe("1. a\n2. \n1) b");
});

it("renumbers past nested children but not past shallower lines", () => {
expect(enterAt("1. a\n - x\n2. b", 4)).toBe("1. a\n2. \n - x\n3. b");
expect(enterAt(" 1. a\n2. b", 6)).toBe(" 1. a\n 2. \n2. b");
});

it("keeps zero padding when renumbering", () => {
expect(enterAt("01. a\n02. b", 5)).toBe("01. a\n02. \n03. b");
});

it("does not add zero padding to wider unpadded numbers", () => {
expect(enterAt("1. a\n10. b", 4)).toBe("1. a\n2. \n3. b");
});

it("compares indentation by width so tab-indented children are skipped", () => {
expect(enterAt(" 1. a\n\t- child\n 2. b", 6)).toBe(" 1. a\n 2. \n\t- child\n 3. b");
});

it("leaves lines inside a multiline quoted mention alone", () => {
const value = '1. @"file\n2. name" \n2. b';
expect(enterAt(value, 3)).toBe('1. \n2. @"file\n2. name" \n3. b');
});

it("returns an expanded caret offset when a chip precedes the caret", () => {
const value = "1. @README.md go\n2. next";
const edit = listContinuationForEnter(value, value.indexOf(" go"))!;
expect(applyEdit(value, edit)).toBe("1. @README.md\n2. go\n3. next");
expect(edit.cursorAfter).toBe(value.indexOf(" go") + "\n2. ".length);
});
});
114 changes: 104 additions & 10 deletions apps/web/src/composer-list-continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export interface ComposerListEdit {
start: number;
end: number;
replacement: string;
/** Expanded caret offset after the edit is applied. */
cursorAfter: number;
}

type ListMarker =
Expand Down Expand Up @@ -64,21 +66,95 @@ function nextMarkerText(marker: ListMarker): string {
return `${marker.indent}${marker.bullet} `;
}

function formatOrderedNumber(value: number, numberText: string): string {
if (!Number.isSafeInteger(value)) return numberText;
// Keep the width only for explicitly zero-padded markers ("01.").
return numberText.startsWith("0")
? String(value).padStart(numberText.length, "0")
: String(value);
}

/** Visual indent width with tabs expanded to four-column stops, as in CommonMark. */
function indentWidth(indent: string): number {
let width = 0;
for (const char of indent) width = char === "\t" ? width + 4 - (width % 4) : width + 1;
return width;
}

/**
* Renumber the ordered items that follow `afterLineEnd` so a newly inserted
* item does not leave duplicates below it. Walks the contiguous run of
* same-indent, same-delimiter items (skipping deeper-indented children) and
* stops at the first line that is not part of that run. Returns the end
* offset of the last renumbered marker and the replacement for the text
* between `afterLineEnd` and that offset.
*/
function renumberFollowingItems(
value: string,
afterLineEnd: number,
marker: Extract<ListMarker, { kind: "ordered" }>,
firstNumber: number,
): { end: number; text: string } | null {
const tokens = inlineTokenRanges(value);
const baseWidth = indentWidth(marker.indent);
let expected = firstNumber;
let position = afterLineEnd;
let end = -1;
let text = "";
let copiedUntil = afterLineEnd;
while (position < value.length) {
const lineStart = position + 1;
const nextBreak = value.indexOf("\n", lineStart);
const lineEnd = nextBreak === -1 ? value.length : nextBreak;
position = lineEnd;
// A line that starts inside a multiline chip is part of that chip's text.
if (tokens.some((token) => lineStart > token.start && lineStart < token.end)) continue;
const line = value.slice(lineStart, lineEnd);
const parsed = parseListMarker(line);
const width = indentWidth(line.match(/^[ \t]*/)?.[0] ?? "");
if (
parsed?.marker.kind === "ordered" &&
width === baseWidth &&
parsed.marker.delimiter === marker.delimiter
) {
const current = parsed.marker;
const numberStart = lineStart + current.indent.length;
const numberEnd = numberStart + current.numberText.length;
const replacement = formatOrderedNumber(expected, current.numberText);
if (replacement !== current.numberText) {
text += value.slice(copiedUntil, numberStart) + replacement;
copiedUntil = numberEnd;
end = numberEnd;
}
expected += 1;
} else if (line.trim() === "" || width <= baseWidth) {
break;
}
}
return end === -1 ? null : { end, text };
}

function segmentSource(
segment: ReturnType<typeof splitPromptIntoComposerSegments>[number],
): string {
if (segment.type === "text") return segment.text;
return segment.source;
}

function isInsideInlineToken(value: string, cursor: number): boolean {
function inlineTokenRanges(value: string): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
let offset = 0;
for (const segment of splitPromptIntoComposerSegments(value)) {
const end = offset + segmentSource(segment).length;
if (segment.type !== "text" && cursor > offset && cursor < end) return true;
if (segment.type !== "text") ranges.push({ start: offset, end });
offset = end;
}
return false;
return ranges;
}

/** True when splitting at the caret would cut an inline chip in two. */
function isInsideInlineToken(value: string, cursor: number): boolean {
return inlineTokenRanges(value).some((range) => cursor > range.start && cursor < range.end);
}

function currentLine(value: string, cursor: number): { start: number; end: number; text: string } {
Expand All @@ -97,13 +173,31 @@ 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() === "") {
return { start: line.start, end: Math.max(cursor, markerEnd), replacement: "" };
return {
start: line.start,
end: Math.max(cursor, markerEnd),
replacement: "",
cursorAfter: line.start,
};
}
const insertion = `\n${nextMarkerText(parsed.marker)}`;
const cursorAfter = cursor + insertion.length;
const { marker } = parsed;
if (marker.kind === "ordered") {
const number = Number.parseInt(marker.numberText, 10);
const renumbered = Number.isSafeInteger(number)
? renumberFollowingItems(value, line.end, marker, number + 2)
: null;
if (renumbered) {
return {
start: cursor,
end: renumbered.end,
replacement: insertion + value.slice(cursor, line.end) + renumbered.text,
cursorAfter,
};
}
}
return {
start: cursor,
end: cursor,
replacement: `\n${nextMarkerText(parsed.marker)}`,
};
return { start: cursor, end: cursor, replacement: insertion, cursorAfter };
}

export function listIndentForTab(
Expand All @@ -115,5 +209,5 @@ export function listIndentForTab(
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: " " };
return { start: line.start, end: line.start, replacement: " ", cursorAfter: start + 2 };
}
Loading