@superdoc/sdk 2.22.0-next.15 → 2.22.0-next.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -165,13 +165,15 @@ function findSnapshotTextByNodeId(snapshot, nodeId) {
165
165
  }
166
166
  function createdBlockTarget(result) {
167
167
  const rec = asRecord(result);
168
- const paragraph = asRecord(rec?.paragraph);
169
- if (typeof paragraph?.nodeId === 'string' && paragraph.nodeId.length > 0) {
170
- return { nodeId: paragraph.nodeId, nodeType: 'paragraph' };
171
- }
172
- const heading = asRecord(rec?.heading);
173
- if (typeof heading?.nodeId === 'string' && heading.nodeId.length > 0) {
174
- return { nodeId: heading.nodeId, nodeType: 'heading' };
168
+ for (const [key, nodeType] of [
169
+ ['paragraph', 'paragraph'],
170
+ ['heading', 'heading'],
171
+ ['item', 'listItem'],
172
+ ]) {
173
+ const block = asRecord(rec?.[key]);
174
+ if (typeof block?.nodeId === 'string' && block.nodeId.length > 0) {
175
+ return { nodeId: block.nodeId, nodeType };
176
+ }
175
177
  }
176
178
  return null;
177
179
  }
@@ -1547,7 +1549,7 @@ async function runInsertListItems(doc, args) {
1547
1549
  }
1548
1550
  }
1549
1551
  async function runAddListItems(doc, args, opts) {
1550
- const pre = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
1552
+ const pre = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
1551
1553
  try {
1552
1554
  const needle = args.anchorText?.trim();
1553
1555
  const anchorNodeId = args.anchorNodeId?.trim();
@@ -1598,25 +1600,47 @@ async function runAddListItems(doc, args, opts) {
1598
1600
  }
1599
1601
  const anchorPath = blockNumbering(anchor)?.path;
1600
1602
  const anchorLevel = Array.isArray(anchorPath) ? Math.max(0, anchorPath.length - 1) : 0;
1603
+ const listByNodeId = new Map((pre.lists ?? []).flatMap((list) => list.items.map((item) => [item.nodeId, list.listId])));
1604
+ const anchorListId = listByNodeId.get(anchor.nodeId);
1605
+ // The lists snapshot is paged, so a missing item is unknown, not foreign.
1606
+ const belongsToAnchorList = (row) => {
1607
+ const rowListId = row.nodeId == null ? undefined : listByNodeId.get(row.nodeId);
1608
+ return anchorListId == null || rowListId == null || rowListId === anchorListId;
1609
+ };
1601
1610
  // Place new items AFTER the anchor's whole sub-tree — every following item
1602
1611
  // whose numbering PATH is nested under the anchor's path (e.g. [12] ⊂ [12,4]).
1603
1612
  // Match by path prefix, not merely "deeper level", and SKIP interleaved
1604
1613
  // non-numbered paragraphs (list-item continuations) rather than stopping at
1605
1614
  // them — real lists wrap sub-items across plain paragraphs. Stop at the first
1606
1615
  // numbered item that is NOT a descendant (a sibling/ancestor, or a different
1607
- // list). Without this, a new same-level item wedges in among the anchor's
1616
+ // unrelated list). Without this, a new same-level item wedges in among the anchor's
1608
1617
  // children and steals the ones after it. No descendants → the anchor itself.
1609
1618
  let subtreeEnd = anchor;
1610
1619
  const anchorPathArr = Array.isArray(anchorPath) ? anchorPath : null;
1611
1620
  const anchorIdx = rows.findIndex((r) => r.nodeId === anchor.nodeId);
1612
1621
  if (anchorPathArr && anchorIdx >= 0) {
1613
1622
  const isDescendant = (p) => Array.isArray(p) && p.length > anchorPathArr.length && anchorPathArr.every((v, k) => p[k] === v);
1623
+ const descendantListIds = new Set();
1624
+ let lastDescendantIdx = anchorIdx;
1614
1625
  for (let i = anchorIdx + 1; i < rows.length; i += 1) {
1615
1626
  const p = blockNumbering(rows[i])?.path;
1616
- if (isDescendant(p)) {
1627
+ const listId = listByNodeId.get(rows[i].nodeId ?? '');
1628
+ const foreign = !belongsToAnchorList(rows[i]) && listId != null;
1629
+ // A separate numbering definition counts on its own, so its path need
1630
+ // not start with the anchor's path ([1, 2] under [2]); go by depth.
1631
+ const nestedForeign = foreign && Array.isArray(p) && p.length > anchorPathArr.length;
1632
+ if (isDescendant(p) || nestedForeign) {
1633
+ if (foreign && !descendantListIds.has(listId)) {
1634
+ // Nested bullets can use a separate numbering definition. Its first
1635
+ // item must be contiguous; a new list after plain text is unrelated.
1636
+ if (i !== lastDescendantIdx + 1)
1637
+ break;
1638
+ descendantListIds.add(listId);
1639
+ }
1617
1640
  subtreeEnd = rows[i]; // a nested child/grandchild of the anchor
1641
+ lastDescendantIdx = i;
1618
1642
  }
1619
- else if (Array.isArray(p)) {
1643
+ else if (Array.isArray(p) || (blockNumbering(rows[i]) && !belongsToAnchorList(rows[i]))) {
1620
1644
  break; // a numbered item that is NOT a descendant → sub-tree ended
1621
1645
  }
1622
1646
  // else: non-numbered paragraph (a continuation) — skip and keep scanning
@@ -1634,30 +1658,55 @@ async function runAddListItems(doc, args, opts) {
1634
1658
  if (firstRelative < 0) {
1635
1659
  const firstTargetLevel = Math.max(0, anchorLevel + firstRelative);
1636
1660
  const startIdx = rows.findIndex((r) => r.nodeId === subtreeEnd.nodeId);
1661
+ const descendantListIds = new Set();
1662
+ let lastDescendantIdx = startIdx;
1637
1663
  for (let i = startIdx + 1; i >= 1 && i < rows.length; i += 1) {
1638
1664
  const level = levelOfRow(rows[i]);
1639
1665
  if (level == null)
1640
1666
  continue; // non-numbered continuation paragraph
1641
1667
  if (level <= firstTargetLevel)
1642
1668
  break;
1669
+ const listId = listByNodeId.get(rows[i].nodeId ?? '');
1670
+ if (!belongsToAnchorList(rows[i]) && listId != null && !descendantListIds.has(listId)) {
1671
+ if (i !== lastDescendantIdx + 1)
1672
+ break;
1673
+ descendantListIds.add(listId);
1674
+ }
1643
1675
  subtreeEnd = rows[i];
1676
+ lastDescendantIdx = i;
1644
1677
  }
1645
1678
  }
1679
+ const anchorKind = blockNumbering(anchor)?.kind ?? null;
1680
+ // Search nearest first within the destination level. A known list may mix
1681
+ // ordered and bullet levels. Keep the numbering-kind fallback only when
1682
+ // the paged snapshot cannot establish that both items share a list.
1683
+ const findSiblingAt = (level) => {
1684
+ const usable = (r) => !!r?.nodeId &&
1685
+ belongsToAnchorList(r) &&
1686
+ levelOfRow(r) === level &&
1687
+ ((anchorListId != null && listByNodeId.get(r.nodeId) === anchorListId) ||
1688
+ (blockNumbering(r)?.kind ?? null) === anchorKind);
1689
+ for (let delta = 1; delta <= rows.length; delta += 1) {
1690
+ const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
1691
+ if (usable(before))
1692
+ return before;
1693
+ const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
1694
+ if (usable(after))
1695
+ return after;
1696
+ }
1697
+ return undefined;
1698
+ };
1699
+ /** The existing item a new one should look like: same level, nearest first. */
1700
+ const formattingSourceForLevel = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
1646
1701
  const attachFn = maybeMethod(doc, ['lists', 'attach']);
1647
- if (!attachFn) {
1648
- throw new errors.SuperDocCliError('doc.lists.attach is not available on the document handle.', {
1702
+ const insertFn = maybeMethod(doc, ['lists', 'insert']);
1703
+ if (!attachFn || !insertFn) {
1704
+ throw new errors.SuperDocCliError('doc.lists.insert and doc.lists.attach are required on the document handle.', {
1649
1705
  code: 'TOOL_DISPATCH_NOT_FOUND',
1650
1706
  });
1651
1707
  }
1652
1708
  const executed = [];
1653
- let placement = {
1654
- kind: 'after',
1655
- target: {
1656
- kind: 'block',
1657
- nodeType: subtreeEnd.nodeType ?? 'listItem',
1658
- nodeId: subtreeEnd.nodeId ?? anchor.nodeId,
1659
- },
1660
- };
1709
+ let insertionAnchorId = subtreeEnd.nodeId ?? anchor.nodeId;
1661
1710
  const createdItems = [];
1662
1711
  for (const entry of args.entries) {
1663
1712
  // Styled items carry ordered `runs`; a plain item is a single run. The
@@ -1670,11 +1719,25 @@ async function runAddListItems(doc, args, opts) {
1670
1719
  // outline level is clamped at 0 (can't go above the root).
1671
1720
  const rel = typeof entry?.level === 'number' ? Math.floor(entry.level) : 0;
1672
1721
  const targetLevel = Math.max(0, anchorLevel + rel);
1673
- const created = await executeCreateParagraph(doc, text, placement, args.changeMode);
1722
+ const formattingSource = formattingSourceForLevel(targetLevel) ?? anchor;
1723
+ // Placement can follow a child with different direction. Inherit from
1724
+ // the intended list level independently of that insertion point.
1725
+ const created = await insertFn({
1726
+ target: { kind: 'block', nodeType: 'listItem', nodeId: insertionAnchorId },
1727
+ position: 'after',
1728
+ text,
1729
+ ...(formattingSource.nodeId !== insertionAnchorId
1730
+ ? { inheritFrom: { kind: 'block', nodeType: 'listItem', nodeId: formattingSource.nodeId } }
1731
+ : {}),
1732
+ ...(args.changeMode ? { changeMode: args.changeMode } : {}),
1733
+ }, args.changeMode ? { changeMode: args.changeMode } : undefined);
1674
1734
  const target = createdBlockTarget(created);
1675
- executed.push({ operationId: 'doc.create.paragraph', result: created });
1676
- if (!target)
1677
- continue;
1735
+ if (!target || target.nodeType !== 'listItem') {
1736
+ throw new errors.SuperDocCliError('doc.lists.insert did not return a created list item.', {
1737
+ code: 'ACTION_FAILED',
1738
+ });
1739
+ }
1740
+ executed.push({ operationId: 'doc.lists.insert', result: created });
1678
1741
  // changeMode is a MutationOption — pass it in the second options arg, not
1679
1742
  // the input (lists.attach reads options.changeMode; input.changeMode is ignored).
1680
1743
  const attachResult = await attachFn({
@@ -1690,53 +1753,10 @@ async function runAddListItems(doc, args, opts) {
1690
1753
  rationale: `level ${targetLevel}`,
1691
1754
  });
1692
1755
  createdItems.push({ nodeId: target.nodeId, targetLevel, text, runs, itemMarks: entry?.marks });
1693
- // After attach the block is a listItem (same nodeId) — the next placement
1694
- // must address it as such or the create resolver rejects the anchor.
1695
- placement = { kind: 'after', target: { kind: 'block', nodeType: 'listItem', nodeId: target.nodeId } };
1696
- }
1697
- // A new item is created next to the ANCHOR, so it inherits the anchor's
1698
- // direct indentation — correct at the same level, wrong at any other one.
1699
- // `lists.attach` sets the numbering level (`w:ilvl`) but leaves that direct
1700
- // `w:ind` alone, so a level-changing add renders with a sub-item's indent
1701
- // under a top-level number: e.g. an item promoted to level 0 kept
1702
- // left=1800 while its "1./2./3." siblings sit at 1080. Copy the indentation
1703
- // from an EXISTING sibling at the item's own level. Best-effort: an
1704
- // unmatched indent is cosmetic and must not fail an item that landed.
1705
- const createdIds = new Set(createdItems.map((ci) => ci.nodeId));
1706
- const anchorKind = blockNumbering(anchor)?.kind ?? null;
1707
- // Search OUTWARD from the anchor, nearest first, and only accept items
1708
- // of the anchor's own numbering kind: a document holds several lists,
1709
- // and the first level-0 item in document order usually belongs to a
1710
- // different one (here: a bullet list far above the ordered list being
1711
- // extended), whose indentation is unrelated.
1712
- const findSiblingAt = (level) => {
1713
- const usable = (r) => !!r?.nodeId &&
1714
- !createdIds.has(r.nodeId) &&
1715
- levelOfRow(r) === level &&
1716
- (blockNumbering(r)?.kind ?? null) === anchorKind;
1717
- for (let delta = 1; delta <= rows.length; delta += 1) {
1718
- const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
1719
- if (usable(before))
1720
- return before;
1721
- const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
1722
- if (usable(after))
1723
- return after;
1724
- }
1725
- return undefined;
1726
- };
1727
- /** The existing item a new one should look like: same level, nearest first. */
1728
- const lookSourceFor = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
1729
- // Most Word documents keep a list's look in a paragraph STYLE, not in direct
1730
- // properties: "MB Agr 2", "Quick 1.", even Heading3 carry the numbering, the
1731
- // indentation and the face. A created paragraph does not get the sibling's
1732
- // style — it gets the anchor's `next` style (Heading3 → BodyText), a default
1733
- // (ListParagraph, whose contextualSpacing changes the spacing), or the
1734
- // anchor's own style when the new item belongs to a DIFFERENT level (keeping
1735
- // "MB Agr 3" on an item whose numbering now says level 2). Reconcile it with
1736
- // the sibling at the item's own level.
1737
- //
1738
- // setStyleRef, not setStyle: setStyle clears direct run formatting, which
1739
- // would wipe the sampled run look applied below.
1756
+ insertionAnchorId = target.nodeId;
1757
+ }
1758
+ // Insertion may select a style's `next` style; keep the destination list level's
1759
+ // style instead. setStyleRef preserves the inherited direct run formatting.
1740
1760
  const styledItems = [];
1741
1761
  const styleSkipped = await bestEffort(async () => {
1742
1762
  const setStyleRefFn = maybeMethod(doc, ['styles', 'paragraph', 'setStyleRef']);
@@ -1745,7 +1765,7 @@ async function runAddListItems(doc, args, opts) {
1745
1765
  return;
1746
1766
  let current = null;
1747
1767
  for (const ci of createdItems) {
1748
- const source = lookSourceFor(ci.targetLevel);
1768
+ const source = formattingSourceForLevel(ci.targetLevel);
1749
1769
  if (!source)
1750
1770
  continue;
1751
1771
  const wanted = source.styleId ?? null;
@@ -1763,6 +1783,8 @@ async function runAddListItems(doc, args, opts) {
1763
1783
  styledItems.push({ nodeId: ci.nodeId, styleId: wanted });
1764
1784
  }
1765
1785
  });
1786
+ // lists.attach changes numbering level without normalizing direct indentation.
1787
+ // Match the existing destination level when its indentation is available.
1766
1788
  const indentedItems = [];
1767
1789
  const indentSkipped = await bestEffort(async () => {
1768
1790
  const setIndentFn = maybeMethod(doc, ['format', 'paragraph', 'setIndentation']);
@@ -1770,7 +1792,7 @@ async function runAddListItems(doc, args, opts) {
1770
1792
  if (!setIndentFn || !getNodeFn)
1771
1793
  return;
1772
1794
  for (const ci of createdItems) {
1773
- const sibling = lookSourceFor(ci.targetLevel);
1795
+ const sibling = formattingSourceForLevel(ci.targetLevel);
1774
1796
  if (!sibling?.nodeId)
1775
1797
  continue;
1776
1798
  const node = (await getNodeFn({ id: sibling.nodeId }));
@@ -1853,7 +1875,7 @@ async function runAddListItems(doc, args, opts) {
1853
1875
  // The BASE layer must come from the row the item will sit beside, not
1854
1876
  // from the anchor: a promoted or demoted item crosses levels, and the
1855
1877
  // anchor's font/size/colour/emphasis is then the wrong level's look.
1856
- // `lookSourceFor(targetLevel)` already picks the destination sibling for
1878
+ // `formattingSourceForLevel(targetLevel)` already picks the destination sibling for
1857
1879
  // style and indent, so the look follows the same source. Falls back to
1858
1880
  // the anchor only when the destination level has no sibling to sample.
1859
1881
  const anchorBase = inlineLookFromRow(anchor);
@@ -1891,7 +1913,7 @@ async function runAddListItems(doc, args, opts) {
1891
1913
  // An item the agent styled itself is left alone by layers 1–2: its
1892
1914
  // marks describe the whole intent, and sampling would fight them.
1893
1915
  const unstyled = ci.itemMarks == null && !ci.runs.some((r) => r.marks);
1894
- const source = unstyled ? lookSourceFor(ci.targetLevel) : undefined;
1916
+ const source = unstyled ? formattingSourceForLevel(ci.targetLevel) : undefined;
1895
1917
  const sourceText = source?.nodeId ? fullTextById.get(source.nodeId) : undefined;
1896
1918
  const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
1897
1919
  // 1. sampled base over the whole item (row fields first, run-level
@@ -1971,7 +1993,7 @@ async function runAddListItems(doc, args, opts) {
1971
1993
  // promotion keeps the plain marker until that read lands.
1972
1994
  let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
1973
1995
  if (!markProps && getNodeFn) {
1974
- const source = lookSourceFor(ci.targetLevel);
1996
+ const source = formattingSourceForLevel(ci.targetLevel);
1975
1997
  if (source?.nodeId)
1976
1998
  markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
1977
1999
  }
@@ -162,13 +162,15 @@ function findSnapshotTextByNodeId(snapshot, nodeId) {
162
162
  }
163
163
  function createdBlockTarget(result) {
164
164
  const rec = asRecord(result);
165
- const paragraph = asRecord(rec?.paragraph);
166
- if (typeof paragraph?.nodeId === 'string' && paragraph.nodeId.length > 0) {
167
- return { nodeId: paragraph.nodeId, nodeType: 'paragraph' };
168
- }
169
- const heading = asRecord(rec?.heading);
170
- if (typeof heading?.nodeId === 'string' && heading.nodeId.length > 0) {
171
- return { nodeId: heading.nodeId, nodeType: 'heading' };
165
+ for (const [key, nodeType] of [
166
+ ['paragraph', 'paragraph'],
167
+ ['heading', 'heading'],
168
+ ['item', 'listItem'],
169
+ ]) {
170
+ const block = asRecord(rec?.[key]);
171
+ if (typeof block?.nodeId === 'string' && block.nodeId.length > 0) {
172
+ return { nodeId: block.nodeId, nodeType };
173
+ }
172
174
  }
173
175
  return null;
174
176
  }
@@ -1544,7 +1546,7 @@ async function runInsertListItems(doc, args) {
1544
1546
  }
1545
1547
  }
1546
1548
  async function runAddListItems(doc, args, opts) {
1547
- const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
1549
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
1548
1550
  try {
1549
1551
  const needle = args.anchorText?.trim();
1550
1552
  const anchorNodeId = args.anchorNodeId?.trim();
@@ -1595,25 +1597,47 @@ async function runAddListItems(doc, args, opts) {
1595
1597
  }
1596
1598
  const anchorPath = blockNumbering(anchor)?.path;
1597
1599
  const anchorLevel = Array.isArray(anchorPath) ? Math.max(0, anchorPath.length - 1) : 0;
1600
+ const listByNodeId = new Map((pre.lists ?? []).flatMap((list) => list.items.map((item) => [item.nodeId, list.listId])));
1601
+ const anchorListId = listByNodeId.get(anchor.nodeId);
1602
+ // The lists snapshot is paged, so a missing item is unknown, not foreign.
1603
+ const belongsToAnchorList = (row) => {
1604
+ const rowListId = row.nodeId == null ? undefined : listByNodeId.get(row.nodeId);
1605
+ return anchorListId == null || rowListId == null || rowListId === anchorListId;
1606
+ };
1598
1607
  // Place new items AFTER the anchor's whole sub-tree — every following item
1599
1608
  // whose numbering PATH is nested under the anchor's path (e.g. [12] ⊂ [12,4]).
1600
1609
  // Match by path prefix, not merely "deeper level", and SKIP interleaved
1601
1610
  // non-numbered paragraphs (list-item continuations) rather than stopping at
1602
1611
  // them — real lists wrap sub-items across plain paragraphs. Stop at the first
1603
1612
  // numbered item that is NOT a descendant (a sibling/ancestor, or a different
1604
- // list). Without this, a new same-level item wedges in among the anchor's
1613
+ // unrelated list). Without this, a new same-level item wedges in among the anchor's
1605
1614
  // children and steals the ones after it. No descendants → the anchor itself.
1606
1615
  let subtreeEnd = anchor;
1607
1616
  const anchorPathArr = Array.isArray(anchorPath) ? anchorPath : null;
1608
1617
  const anchorIdx = rows.findIndex((r) => r.nodeId === anchor.nodeId);
1609
1618
  if (anchorPathArr && anchorIdx >= 0) {
1610
1619
  const isDescendant = (p) => Array.isArray(p) && p.length > anchorPathArr.length && anchorPathArr.every((v, k) => p[k] === v);
1620
+ const descendantListIds = new Set();
1621
+ let lastDescendantIdx = anchorIdx;
1611
1622
  for (let i = anchorIdx + 1; i < rows.length; i += 1) {
1612
1623
  const p = blockNumbering(rows[i])?.path;
1613
- if (isDescendant(p)) {
1624
+ const listId = listByNodeId.get(rows[i].nodeId ?? '');
1625
+ const foreign = !belongsToAnchorList(rows[i]) && listId != null;
1626
+ // A separate numbering definition counts on its own, so its path need
1627
+ // not start with the anchor's path ([1, 2] under [2]); go by depth.
1628
+ const nestedForeign = foreign && Array.isArray(p) && p.length > anchorPathArr.length;
1629
+ if (isDescendant(p) || nestedForeign) {
1630
+ if (foreign && !descendantListIds.has(listId)) {
1631
+ // Nested bullets can use a separate numbering definition. Its first
1632
+ // item must be contiguous; a new list after plain text is unrelated.
1633
+ if (i !== lastDescendantIdx + 1)
1634
+ break;
1635
+ descendantListIds.add(listId);
1636
+ }
1614
1637
  subtreeEnd = rows[i]; // a nested child/grandchild of the anchor
1638
+ lastDescendantIdx = i;
1615
1639
  }
1616
- else if (Array.isArray(p)) {
1640
+ else if (Array.isArray(p) || (blockNumbering(rows[i]) && !belongsToAnchorList(rows[i]))) {
1617
1641
  break; // a numbered item that is NOT a descendant → sub-tree ended
1618
1642
  }
1619
1643
  // else: non-numbered paragraph (a continuation) — skip and keep scanning
@@ -1631,30 +1655,55 @@ async function runAddListItems(doc, args, opts) {
1631
1655
  if (firstRelative < 0) {
1632
1656
  const firstTargetLevel = Math.max(0, anchorLevel + firstRelative);
1633
1657
  const startIdx = rows.findIndex((r) => r.nodeId === subtreeEnd.nodeId);
1658
+ const descendantListIds = new Set();
1659
+ let lastDescendantIdx = startIdx;
1634
1660
  for (let i = startIdx + 1; i >= 1 && i < rows.length; i += 1) {
1635
1661
  const level = levelOfRow(rows[i]);
1636
1662
  if (level == null)
1637
1663
  continue; // non-numbered continuation paragraph
1638
1664
  if (level <= firstTargetLevel)
1639
1665
  break;
1666
+ const listId = listByNodeId.get(rows[i].nodeId ?? '');
1667
+ if (!belongsToAnchorList(rows[i]) && listId != null && !descendantListIds.has(listId)) {
1668
+ if (i !== lastDescendantIdx + 1)
1669
+ break;
1670
+ descendantListIds.add(listId);
1671
+ }
1640
1672
  subtreeEnd = rows[i];
1673
+ lastDescendantIdx = i;
1641
1674
  }
1642
1675
  }
1676
+ const anchorKind = blockNumbering(anchor)?.kind ?? null;
1677
+ // Search nearest first within the destination level. A known list may mix
1678
+ // ordered and bullet levels. Keep the numbering-kind fallback only when
1679
+ // the paged snapshot cannot establish that both items share a list.
1680
+ const findSiblingAt = (level) => {
1681
+ const usable = (r) => !!r?.nodeId &&
1682
+ belongsToAnchorList(r) &&
1683
+ levelOfRow(r) === level &&
1684
+ ((anchorListId != null && listByNodeId.get(r.nodeId) === anchorListId) ||
1685
+ (blockNumbering(r)?.kind ?? null) === anchorKind);
1686
+ for (let delta = 1; delta <= rows.length; delta += 1) {
1687
+ const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
1688
+ if (usable(before))
1689
+ return before;
1690
+ const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
1691
+ if (usable(after))
1692
+ return after;
1693
+ }
1694
+ return undefined;
1695
+ };
1696
+ /** The existing item a new one should look like: same level, nearest first. */
1697
+ const formattingSourceForLevel = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
1643
1698
  const attachFn = maybeMethod(doc, ['lists', 'attach']);
1644
- if (!attachFn) {
1645
- throw new SuperDocCliError('doc.lists.attach is not available on the document handle.', {
1699
+ const insertFn = maybeMethod(doc, ['lists', 'insert']);
1700
+ if (!attachFn || !insertFn) {
1701
+ throw new SuperDocCliError('doc.lists.insert and doc.lists.attach are required on the document handle.', {
1646
1702
  code: 'TOOL_DISPATCH_NOT_FOUND',
1647
1703
  });
1648
1704
  }
1649
1705
  const executed = [];
1650
- let placement = {
1651
- kind: 'after',
1652
- target: {
1653
- kind: 'block',
1654
- nodeType: subtreeEnd.nodeType ?? 'listItem',
1655
- nodeId: subtreeEnd.nodeId ?? anchor.nodeId,
1656
- },
1657
- };
1706
+ let insertionAnchorId = subtreeEnd.nodeId ?? anchor.nodeId;
1658
1707
  const createdItems = [];
1659
1708
  for (const entry of args.entries) {
1660
1709
  // Styled items carry ordered `runs`; a plain item is a single run. The
@@ -1667,11 +1716,25 @@ async function runAddListItems(doc, args, opts) {
1667
1716
  // outline level is clamped at 0 (can't go above the root).
1668
1717
  const rel = typeof entry?.level === 'number' ? Math.floor(entry.level) : 0;
1669
1718
  const targetLevel = Math.max(0, anchorLevel + rel);
1670
- const created = await executeCreateParagraph(doc, text, placement, args.changeMode);
1719
+ const formattingSource = formattingSourceForLevel(targetLevel) ?? anchor;
1720
+ // Placement can follow a child with different direction. Inherit from
1721
+ // the intended list level independently of that insertion point.
1722
+ const created = await insertFn({
1723
+ target: { kind: 'block', nodeType: 'listItem', nodeId: insertionAnchorId },
1724
+ position: 'after',
1725
+ text,
1726
+ ...(formattingSource.nodeId !== insertionAnchorId
1727
+ ? { inheritFrom: { kind: 'block', nodeType: 'listItem', nodeId: formattingSource.nodeId } }
1728
+ : {}),
1729
+ ...(args.changeMode ? { changeMode: args.changeMode } : {}),
1730
+ }, args.changeMode ? { changeMode: args.changeMode } : undefined);
1671
1731
  const target = createdBlockTarget(created);
1672
- executed.push({ operationId: 'doc.create.paragraph', result: created });
1673
- if (!target)
1674
- continue;
1732
+ if (!target || target.nodeType !== 'listItem') {
1733
+ throw new SuperDocCliError('doc.lists.insert did not return a created list item.', {
1734
+ code: 'ACTION_FAILED',
1735
+ });
1736
+ }
1737
+ executed.push({ operationId: 'doc.lists.insert', result: created });
1675
1738
  // changeMode is a MutationOption — pass it in the second options arg, not
1676
1739
  // the input (lists.attach reads options.changeMode; input.changeMode is ignored).
1677
1740
  const attachResult = await attachFn({
@@ -1687,53 +1750,10 @@ async function runAddListItems(doc, args, opts) {
1687
1750
  rationale: `level ${targetLevel}`,
1688
1751
  });
1689
1752
  createdItems.push({ nodeId: target.nodeId, targetLevel, text, runs, itemMarks: entry?.marks });
1690
- // After attach the block is a listItem (same nodeId) — the next placement
1691
- // must address it as such or the create resolver rejects the anchor.
1692
- placement = { kind: 'after', target: { kind: 'block', nodeType: 'listItem', nodeId: target.nodeId } };
1693
- }
1694
- // A new item is created next to the ANCHOR, so it inherits the anchor's
1695
- // direct indentation — correct at the same level, wrong at any other one.
1696
- // `lists.attach` sets the numbering level (`w:ilvl`) but leaves that direct
1697
- // `w:ind` alone, so a level-changing add renders with a sub-item's indent
1698
- // under a top-level number: e.g. an item promoted to level 0 kept
1699
- // left=1800 while its "1./2./3." siblings sit at 1080. Copy the indentation
1700
- // from an EXISTING sibling at the item's own level. Best-effort: an
1701
- // unmatched indent is cosmetic and must not fail an item that landed.
1702
- const createdIds = new Set(createdItems.map((ci) => ci.nodeId));
1703
- const anchorKind = blockNumbering(anchor)?.kind ?? null;
1704
- // Search OUTWARD from the anchor, nearest first, and only accept items
1705
- // of the anchor's own numbering kind: a document holds several lists,
1706
- // and the first level-0 item in document order usually belongs to a
1707
- // different one (here: a bullet list far above the ordered list being
1708
- // extended), whose indentation is unrelated.
1709
- const findSiblingAt = (level) => {
1710
- const usable = (r) => !!r?.nodeId &&
1711
- !createdIds.has(r.nodeId) &&
1712
- levelOfRow(r) === level &&
1713
- (blockNumbering(r)?.kind ?? null) === anchorKind;
1714
- for (let delta = 1; delta <= rows.length; delta += 1) {
1715
- const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
1716
- if (usable(before))
1717
- return before;
1718
- const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
1719
- if (usable(after))
1720
- return after;
1721
- }
1722
- return undefined;
1723
- };
1724
- /** The existing item a new one should look like: same level, nearest first. */
1725
- const lookSourceFor = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
1726
- // Most Word documents keep a list's look in a paragraph STYLE, not in direct
1727
- // properties: "MB Agr 2", "Quick 1.", even Heading3 carry the numbering, the
1728
- // indentation and the face. A created paragraph does not get the sibling's
1729
- // style — it gets the anchor's `next` style (Heading3 → BodyText), a default
1730
- // (ListParagraph, whose contextualSpacing changes the spacing), or the
1731
- // anchor's own style when the new item belongs to a DIFFERENT level (keeping
1732
- // "MB Agr 3" on an item whose numbering now says level 2). Reconcile it with
1733
- // the sibling at the item's own level.
1734
- //
1735
- // setStyleRef, not setStyle: setStyle clears direct run formatting, which
1736
- // would wipe the sampled run look applied below.
1753
+ insertionAnchorId = target.nodeId;
1754
+ }
1755
+ // Insertion may select a style's `next` style; keep the destination list level's
1756
+ // style instead. setStyleRef preserves the inherited direct run formatting.
1737
1757
  const styledItems = [];
1738
1758
  const styleSkipped = await bestEffort(async () => {
1739
1759
  const setStyleRefFn = maybeMethod(doc, ['styles', 'paragraph', 'setStyleRef']);
@@ -1742,7 +1762,7 @@ async function runAddListItems(doc, args, opts) {
1742
1762
  return;
1743
1763
  let current = null;
1744
1764
  for (const ci of createdItems) {
1745
- const source = lookSourceFor(ci.targetLevel);
1765
+ const source = formattingSourceForLevel(ci.targetLevel);
1746
1766
  if (!source)
1747
1767
  continue;
1748
1768
  const wanted = source.styleId ?? null;
@@ -1760,6 +1780,8 @@ async function runAddListItems(doc, args, opts) {
1760
1780
  styledItems.push({ nodeId: ci.nodeId, styleId: wanted });
1761
1781
  }
1762
1782
  });
1783
+ // lists.attach changes numbering level without normalizing direct indentation.
1784
+ // Match the existing destination level when its indentation is available.
1763
1785
  const indentedItems = [];
1764
1786
  const indentSkipped = await bestEffort(async () => {
1765
1787
  const setIndentFn = maybeMethod(doc, ['format', 'paragraph', 'setIndentation']);
@@ -1767,7 +1789,7 @@ async function runAddListItems(doc, args, opts) {
1767
1789
  if (!setIndentFn || !getNodeFn)
1768
1790
  return;
1769
1791
  for (const ci of createdItems) {
1770
- const sibling = lookSourceFor(ci.targetLevel);
1792
+ const sibling = formattingSourceForLevel(ci.targetLevel);
1771
1793
  if (!sibling?.nodeId)
1772
1794
  continue;
1773
1795
  const node = (await getNodeFn({ id: sibling.nodeId }));
@@ -1850,7 +1872,7 @@ async function runAddListItems(doc, args, opts) {
1850
1872
  // The BASE layer must come from the row the item will sit beside, not
1851
1873
  // from the anchor: a promoted or demoted item crosses levels, and the
1852
1874
  // anchor's font/size/colour/emphasis is then the wrong level's look.
1853
- // `lookSourceFor(targetLevel)` already picks the destination sibling for
1875
+ // `formattingSourceForLevel(targetLevel)` already picks the destination sibling for
1854
1876
  // style and indent, so the look follows the same source. Falls back to
1855
1877
  // the anchor only when the destination level has no sibling to sample.
1856
1878
  const anchorBase = inlineLookFromRow(anchor);
@@ -1888,7 +1910,7 @@ async function runAddListItems(doc, args, opts) {
1888
1910
  // An item the agent styled itself is left alone by layers 1–2: its
1889
1911
  // marks describe the whole intent, and sampling would fight them.
1890
1912
  const unstyled = ci.itemMarks == null && !ci.runs.some((r) => r.marks);
1891
- const source = unstyled ? lookSourceFor(ci.targetLevel) : undefined;
1913
+ const source = unstyled ? formattingSourceForLevel(ci.targetLevel) : undefined;
1892
1914
  const sourceText = source?.nodeId ? fullTextById.get(source.nodeId) : undefined;
1893
1915
  const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
1894
1916
  // 1. sampled base over the whole item (row fields first, run-level
@@ -1968,7 +1990,7 @@ async function runAddListItems(doc, args, opts) {
1968
1990
  // promotion keeps the plain marker until that read lands.
1969
1991
  let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
1970
1992
  if (!markProps && getNodeFn) {
1971
- const source = lookSourceFor(ci.targetLevel);
1993
+ const source = formattingSourceForLevel(ci.targetLevel);
1972
1994
  if (source?.nodeId)
1973
1995
  markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
1974
1996
  }