@wrongstack/tui 0.10.2 → 0.24.0

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.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { PassThrough } from 'stream';
1
+ import { Readable } from 'stream';
2
2
  import { Box, Text, render, useApp, useStdout, measureElement, Static, useInput, useStdin } from 'ink';
3
- import React4, { useState, useEffect, useReducer, useRef, useMemo, useLayoutEffect } from 'react';
3
+ import React4, { useState, useEffect, useReducer, useRef, useMemo, useCallback, useLayoutEffect } from 'react';
4
4
  import * as fs2 from 'fs/promises';
5
5
  import * as path2 from 'path';
6
6
  import { InputBuilder, DefaultSessionRewinder, formatTodosList, buildGoalPreamble, buildChildEnv } from '@wrongstack/core';
@@ -35,6 +35,8 @@ var theme = Object.freeze({
35
35
  diffAddBg: "greenBright",
36
36
  diffDelBg: "redBright"
37
37
  });
38
+ var COMPACT_THRESHOLD = 50;
39
+ var COMFORTABLE_THRESHOLD = 90;
38
40
  function StatusBar({
39
41
  model,
40
42
  version,
@@ -58,6 +60,18 @@ function StatusBar({
58
60
  eternalStage,
59
61
  goalSummary
60
62
  }) {
63
+ const { stdout } = useStdout();
64
+ const [termWidth, setTermWidth] = useState(stdout?.columns ?? 90);
65
+ useEffect(() => {
66
+ const handleResize = () => setTermWidth(stdout?.columns ?? 90);
67
+ handleResize();
68
+ process.stdout.on("resize", handleResize);
69
+ return () => {
70
+ process.stdout.off("resize", handleResize);
71
+ };
72
+ }, [stdout]);
73
+ const isCompact = termWidth < COMPACT_THRESHOLD;
74
+ const isComfortable = termWidth >= COMFORTABLE_THRESHOLD;
61
75
  const hiddenSet = new Set(hiddenItems);
62
76
  const usage = tokenCounter?.total();
63
77
  const cost = tokenCounter?.estimateCost();
@@ -77,74 +91,87 @@ function StatusBar({
77
91
  borderLeft: false,
78
92
  borderRight: false,
79
93
  children: [
80
- /* @__PURE__ */ jsxs(Box, { flexDirection: "row", gap: 2, children: [
81
- version ? /* @__PURE__ */ jsxs(Fragment, { children: [
82
- /* @__PURE__ */ jsxs(Text, { children: [
83
- /* @__PURE__ */ jsx(Text, { color: "blue", bold: true, children: "WS" }),
84
- /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
85
- " v",
86
- version
87
- ] })
94
+ /* @__PURE__ */ jsx(Box, { flexDirection: "row", gap: 2, children: isCompact ? (
95
+ // Ultra-compact: state · model
96
+ /* @__PURE__ */ jsxs(Fragment, { children: [
97
+ /* @__PURE__ */ jsxs(Text, { color: stateColor, children: [
98
+ "\u25CF",
99
+ stateLabel
100
+ ] }),
101
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\xB7" }),
102
+ /* @__PURE__ */ jsx(Text, { color: "magenta", children: model })
103
+ ] })
104
+ ) : (
105
+ // Full mode: version · state · model · context · tokens · cost · queue · processes · hint
106
+ /* @__PURE__ */ jsxs(Fragment, { children: [
107
+ version ? /* @__PURE__ */ jsxs(Fragment, { children: [
108
+ /* @__PURE__ */ jsxs(Text, { children: [
109
+ /* @__PURE__ */ jsx(Text, { color: "blue", bold: true, children: "WS" }),
110
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
111
+ " v",
112
+ version
113
+ ] })
114
+ ] }),
115
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" })
116
+ ] }) : null,
117
+ /* @__PURE__ */ jsxs(Text, { color: stateColor, children: [
118
+ "\u25CF ",
119
+ stateLabel
88
120
  ] }),
89
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" })
90
- ] }) : null,
91
- /* @__PURE__ */ jsxs(Text, { color: stateColor, children: [
92
- "\u25CF ",
93
- stateLabel
94
- ] }),
95
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
96
- /* @__PURE__ */ jsx(Text, { color: "magenta", children: model }),
97
- context && context.max > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
98
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
99
- /* @__PURE__ */ jsx(ContextChip, { ctx: context })
100
- ] }) : null,
101
- usage ? /* @__PURE__ */ jsxs(Fragment, { children: [
102
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
103
- /* @__PURE__ */ jsxs(Text, { children: [
104
- "\u2191",
105
- " ",
106
- /* @__PURE__ */ jsx(Text, { color: "cyan", children: fmtTok(usage.input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)) }),
107
- " ",
108
- "\u2193 ",
109
- /* @__PURE__ */ jsx(Text, { color: "cyan", children: fmtTok(usage.output) })
110
- ] })
111
- ] }) : null,
112
- cache2 && cache2.hitRatio > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
113
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
114
- /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
115
- "cache ",
116
- (cache2.hitRatio * 100).toFixed(0),
117
- "%"
118
- ] })
119
- ] }) : null,
120
- cost && cost.total > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
121
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
122
- /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
123
- "$",
124
- cost.total.toFixed(4)
125
- ] })
126
- ] }) : null,
127
- queueCount > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
128
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
129
- /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
130
- "\u231B queued: ",
131
- queueCount
132
- ] })
133
- ] }) : null,
134
- typeof processCount === "number" && processCount > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
135
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
136
- /* @__PURE__ */ jsxs(Text, { color: "red", children: [
137
- "\u26A1 ",
138
- processCount,
139
- " process",
140
- processCount === 1 ? "" : "es"
141
- ] })
142
- ] }) : null,
143
- hint ? /* @__PURE__ */ jsxs(Fragment, { children: [
144
121
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
145
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: hint })
146
- ] }) : null
147
- ] }),
122
+ /* @__PURE__ */ jsx(Text, { color: "magenta", children: model }),
123
+ context && context.max > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
124
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
125
+ /* @__PURE__ */ jsx(ContextChip, { ctx: context })
126
+ ] }) : null,
127
+ usage && isComfortable ? /* @__PURE__ */ jsxs(Fragment, { children: [
128
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
129
+ /* @__PURE__ */ jsxs(Text, { children: [
130
+ "\u2191",
131
+ " ",
132
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: fmtTok(usage.input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)) }),
133
+ " ",
134
+ "\u2193 ",
135
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: fmtTok(usage.output) })
136
+ ] })
137
+ ] }) : null,
138
+ cache2 && cache2.hitRatio > 0 && isComfortable ? /* @__PURE__ */ jsxs(Fragment, { children: [
139
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
140
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
141
+ "cache ",
142
+ (cache2.hitRatio * 100).toFixed(0),
143
+ "%"
144
+ ] })
145
+ ] }) : null,
146
+ cost && cost.total > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
147
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
148
+ /* @__PURE__ */ jsxs(Text, { color: "yellow", children: [
149
+ "$",
150
+ cost.total.toFixed(4)
151
+ ] })
152
+ ] }) : null,
153
+ queueCount > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
154
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
155
+ /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
156
+ "\u231B queued: ",
157
+ queueCount
158
+ ] })
159
+ ] }) : null,
160
+ typeof processCount === "number" && processCount > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
161
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
162
+ /* @__PURE__ */ jsxs(Text, { color: "red", children: [
163
+ "\u26A1 ",
164
+ processCount,
165
+ " process",
166
+ processCount === 1 ? "" : "es"
167
+ ] })
168
+ ] }) : null,
169
+ hint ? /* @__PURE__ */ jsxs(Fragment, { children: [
170
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502" }),
171
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: hint })
172
+ ] }) : null
173
+ ] })
174
+ ) }),
148
175
  hasSecondLine ? /* @__PURE__ */ jsxs(Box, { flexDirection: "row", gap: 2, children: [
149
176
  yolo ? /* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: "\u26A0 YOLO" }) : null,
150
177
  autonomy && autonomy !== "off" ? /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -855,21 +882,58 @@ function AgentsMonitor({
855
882
  ] });
856
883
  }
857
884
  var AUTONOMY_OPTIONS = [
858
- { mode: "off", label: "OFF", description: "Agent stops after each turn (normal interactive mode)", color: "green" },
859
- { mode: "suggest", label: "SUGGEST", description: "Shows next-step suggestions after each turn", color: "cyan" },
860
- { mode: "auto", label: "AUTO", description: "Self-driving \u2014 agent picks next step and continues", color: "yellow" },
861
- { mode: "eternal", label: "ETERNAL", description: "Goal-driven loop \u2014 requires /goal set first", color: "red" },
862
- { mode: "eternal-parallel", label: "PARALLEL", description: "Fan-out 4\u20138 subagents per tick \u2014 requires /goal", color: "magenta" }
885
+ {
886
+ mode: "off",
887
+ label: "OFF",
888
+ description: "Agent stops after each turn (normal interactive mode)",
889
+ color: "green"
890
+ },
891
+ {
892
+ mode: "suggest",
893
+ label: "SUGGEST",
894
+ description: "Shows next-step suggestions after each turn",
895
+ color: "cyan"
896
+ },
897
+ {
898
+ mode: "auto",
899
+ label: "AUTO",
900
+ description: "Self-driving \u2014 agent picks next step and continues",
901
+ color: "yellow"
902
+ },
903
+ {
904
+ mode: "eternal",
905
+ label: "ETERNAL",
906
+ description: "Goal-driven loop \u2014 requires /goal set first",
907
+ color: "red"
908
+ },
909
+ {
910
+ mode: "eternal-parallel",
911
+ label: "PARALLEL",
912
+ description: "Fan-out 4\u20138 subagents per tick \u2014 requires /goal",
913
+ color: "magenta"
914
+ }
863
915
  ];
864
- function AutonomyPicker({ options, selected, hint }) {
916
+ function AutonomyPicker({
917
+ options,
918
+ selected,
919
+ hint
920
+ }) {
865
921
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
866
922
  /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "\u2501\u2501 Autonomy Mode \u2501\u2501" }),
867
923
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \xB7 Enter select \xB7 Esc cancel \xB7 Ctrl+C exit" }),
868
- options.map((opt, i) => /* @__PURE__ */ jsxs(Text, { color: i === selected ? opt.color : void 0, inverse: i === selected, children: [
869
- i === selected ? "\u203A " : " ",
870
- /* @__PURE__ */ jsx(Text, { bold: true, children: opt.label.padEnd(12) }),
871
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: opt.description })
872
- ] }, opt.mode)),
924
+ options.map((opt, i) => /* @__PURE__ */ jsxs(
925
+ Text,
926
+ {
927
+ color: i === selected ? opt.color : void 0,
928
+ inverse: i === selected,
929
+ children: [
930
+ i === selected ? "\u203A " : " ",
931
+ /* @__PURE__ */ jsx(Text, { bold: true, children: opt.label.padEnd(12) }),
932
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: opt.description })
933
+ ]
934
+ },
935
+ opt.mode
936
+ )),
873
937
  hint ? /* @__PURE__ */ jsx(Text, { color: "yellow", children: hint }) : null
874
938
  ] });
875
939
  }
@@ -907,7 +971,8 @@ function CheckpointTimeline({
907
971
  new Date(cp.ts).toLocaleTimeString()
908
972
  ] }),
909
973
  cp.fileCount > 0 && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
910
- " \xB7 ",
974
+ " ",
975
+ "\xB7 ",
911
976
  cp.fileCount,
912
977
  " file",
913
978
  cp.fileCount !== 1 ? "s" : ""
@@ -1028,9 +1093,24 @@ function FilePicker({ query, matches, selected }) {
1028
1093
  function highlight(path3, _query) {
1029
1094
  return path3;
1030
1095
  }
1031
- function FleetPanel({ entries, totalCost, collabSession }) {
1096
+ function FleetPanel({
1097
+ entries,
1098
+ totalCost,
1099
+ collabSession
1100
+ }) {
1101
+ const { stdout } = useStdout();
1102
+ const [termWidth, setTermWidth] = useState(stdout?.columns ?? 90);
1103
+ useEffect(() => {
1104
+ const handleResize = () => setTermWidth(stdout?.columns ?? 90);
1105
+ handleResize();
1106
+ process.stdout.on("resize", handleResize);
1107
+ return () => {
1108
+ process.stdout.off("resize", handleResize);
1109
+ };
1110
+ }, [stdout]);
1032
1111
  const list = Object.values(entries);
1033
1112
  if (list.length === 0 && !collabSession) return null;
1113
+ const nameMaxLen = Math.max(6, Math.min(14, termWidth - 30));
1034
1114
  const leader = list.find((e) => e.id === "leader");
1035
1115
  const subagents = list.filter((e) => e.id !== "leader");
1036
1116
  const running = subagents.filter((e) => e.status === "running");
@@ -1050,16 +1130,16 @@ function FleetPanel({ entries, totalCost, collabSession }) {
1050
1130
  ] }),
1051
1131
  hasCollab && leader ? /* @__PURE__ */ jsxs(Box, { flexDirection: "row", gap: 1, children: [
1052
1132
  /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CF" }),
1053
- /* @__PURE__ */ jsx(Text, { children: leader.name.slice(0, 14).padEnd(14) }),
1133
+ /* @__PURE__ */ jsx(Text, { children: leader.name.slice(0, nameMaxLen) }),
1054
1134
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2192" }),
1055
1135
  /* @__PURE__ */ jsx(Text, { color: "yellow", children: leaderTool })
1056
1136
  ] }) : null,
1057
1137
  shown.map((entry) => {
1058
- const name = entry.name && entry.name !== entry.id ? entry.name : entry.id.slice(0, 8);
1138
+ const name = entry.name && entry.name !== entry.id ? entry.name : entry.id.slice(0, nameMaxLen);
1059
1139
  const tool = entry.currentTool?.name ?? "\u2014";
1060
1140
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "row", gap: 1, children: [
1061
1141
  /* @__PURE__ */ jsx(Text, { color: "green", children: "\u25CF" }),
1062
- /* @__PURE__ */ jsx(Text, { children: name.slice(0, 14).padEnd(14) }),
1142
+ /* @__PURE__ */ jsx(Text, { children: name.slice(0, nameMaxLen) }),
1063
1143
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2192" }),
1064
1144
  /* @__PURE__ */ jsx(Text, { color: "cyan", children: tool })
1065
1145
  ] }, entry.id);
@@ -1069,7 +1149,7 @@ function FleetPanel({ entries, totalCost, collabSession }) {
1069
1149
  "+",
1070
1150
  overflow,
1071
1151
  ": ",
1072
- running[3]?.name?.slice(0, 12) ?? "agent",
1152
+ running[3]?.name?.slice(0, nameMaxLen - 2) ?? "agent",
1073
1153
  "\u2026"
1074
1154
  ] }) : null
1075
1155
  ] });
@@ -1542,7 +1622,8 @@ function tokenizePython(line, carry) {
1542
1622
  }
1543
1623
  function tokenizeDiff(line) {
1544
1624
  if (line.startsWith("@@")) return [{ text: line, color: C.diffMeta }];
1545
- if (line.startsWith("+++") || line.startsWith("---")) return [{ text: line, color: C.diffMeta, dim: true }];
1625
+ if (line.startsWith("+++") || line.startsWith("---"))
1626
+ return [{ text: line, color: C.diffMeta, dim: true }];
1546
1627
  if (line.startsWith("+")) return [{ text: line, color: C.diffAdd }];
1547
1628
  if (line.startsWith("-")) return [{ text: line, color: C.diffDel }];
1548
1629
  return [{ text: line, dim: true }];
@@ -1631,6 +1712,14 @@ function parseAlign(sep2) {
1631
1712
  if (right) return "right";
1632
1713
  return "left";
1633
1714
  }
1715
+ function parseSeparatorWidths(sepCells) {
1716
+ return sepCells.map((cell) => {
1717
+ const trimmed = cell.trim();
1718
+ const dashes = trimmed.replace(/:/g, "");
1719
+ if (/^-+$/.test(dashes)) return dashes.length;
1720
+ return null;
1721
+ });
1722
+ }
1634
1723
  function renderTable(tableLines, maxWidth) {
1635
1724
  const header = parseCells(tableLines[0] ?? "");
1636
1725
  const sepCells = parseCells(tableLines[1] ?? "");
@@ -1644,7 +1733,8 @@ function renderTable(tableLines, maxWidth) {
1644
1733
  while (row.length < cols) row.push("");
1645
1734
  row.length = cols;
1646
1735
  }
1647
- const widths = computeWidths([header, ...dataRows], cols, maxWidth);
1736
+ const sepWidths = parseSeparatorWidths(sepCells);
1737
+ const widths = computeWidths([header, ...dataRows], cols, maxWidth, sepWidths);
1648
1738
  const lines = [];
1649
1739
  lines.push(border("\u250C", "\u252C", "\u2510", widths));
1650
1740
  lines.push(...renderRow(header, widths, aligns));
@@ -1655,7 +1745,7 @@ function renderTable(tableLines, maxWidth) {
1655
1745
  lines.push(border("\u2514", "\u2534", "\u2518", widths));
1656
1746
  return lines.join("\n");
1657
1747
  }
1658
- function computeWidths(allRows, cols, maxWidth) {
1748
+ function computeWidths(allRows, cols, maxWidth, sepWidths) {
1659
1749
  const overhead = 3 * cols + 1;
1660
1750
  const avail = Math.max(cols * MIN_COL_WIDTH, maxWidth - overhead);
1661
1751
  const natural = new Array(cols).fill(0);
@@ -1663,9 +1753,16 @@ function computeWidths(allRows, cols, maxWidth) {
1663
1753
  for (let c = 0; c < cols; c++) {
1664
1754
  const cell = row[c] ?? "";
1665
1755
  const w = longestWord(cell);
1666
- const total = cell.length;
1667
- natural[c] = Math.max(natural[c], total);
1668
- if (w > natural[c]) natural[c] = Math.min(total + 1, w);
1756
+ const total = strWidth(cell);
1757
+ natural[c] = Math.max(natural[c], w, total);
1758
+ }
1759
+ }
1760
+ if (sepWidths) {
1761
+ for (let c = 0; c < cols && c < sepWidths.length; c++) {
1762
+ const sepW = sepWidths[c];
1763
+ if (sepW != null) {
1764
+ natural[c] = Math.max(natural[c], sepW);
1765
+ }
1669
1766
  }
1670
1767
  }
1671
1768
  const sumNatural = natural.reduce((s2, n) => s2 + n, 0);
@@ -1689,9 +1786,51 @@ function computeWidths(allRows, cols, maxWidth) {
1689
1786
  return widths;
1690
1787
  }
1691
1788
  var MIN_COL_WIDTH = 4;
1789
+ function strWidth(s2) {
1790
+ let width = 0;
1791
+ for (const cp of s2) {
1792
+ const code = cp.codePointAt(0);
1793
+ if (code < 32 || code >= 127 && code < 160) {
1794
+ continue;
1795
+ }
1796
+ if (code >= 126976 || // Supplementary Pictographs (U+1F000-U+1FFFF)
1797
+ code >= 9728 && code <= 10175 || // Miscellaneous Symbols, Dingbats
1798
+ code >= 8960 && code <= 9215 || // Miscellaneous Technical
1799
+ code >= 11088 && code <= 11093 || // Stars and similar
1800
+ code >= 10548 && code <= 10549 || // Arrow forms
1801
+ code >= 8592 && code <= 8703 || // Arrows
1802
+ code >= 9632 && code <= 9727 || // Geometric Shapes
1803
+ code >= 9664 && code <= 9726 || // More Geometric Shapes (includes ▶)
1804
+ code >= 9984 && code <= 10175) {
1805
+ width += 2;
1806
+ continue;
1807
+ }
1808
+ if (code >= 4352 && code <= 4447 || // Hangul Jamo
1809
+ code === 9001 || // LEFT-POINTING ANGLE BRACKET
1810
+ code === 9002 || // RIGHT-POINTING ANGLE BRACKET
1811
+ code >= 11904 && code <= 12350 || // CJK Radicals Supplement
1812
+ code >= 12352 && code <= 42191 || // Hiragana, Katakana, CJK
1813
+ code >= 44032 && code <= 55203 || // Hangul Syllables
1814
+ code >= 63744 && code <= 64249 || // CJK Compatibility Ideographs
1815
+ code >= 65040 && code <= 65055 || // Vertical forms
1816
+ code >= 65072 && code <= 65135 || // CJK Compatibility Forms
1817
+ code >= 65280 && code <= 65376 || // Fullwidth Forms
1818
+ code >= 65504 && code <= 65510 || // Halfwidth and Fullwidth Forms
1819
+ code >= 131072 && code <= 196605 || // CJK Extension B+
1820
+ code >= 196608 && code <= 262141) {
1821
+ width += 2;
1822
+ continue;
1823
+ }
1824
+ width += 1;
1825
+ }
1826
+ return width;
1827
+ }
1692
1828
  function longestWord(s2) {
1693
1829
  let max = 0;
1694
- for (const w of s2.split(/\s+/)) if (w.length > max) max = w.length;
1830
+ for (const w of s2.split(/\s+/)) {
1831
+ const visualWidth = strWidth(w);
1832
+ if (visualWidth > max) max = visualWidth;
1833
+ }
1695
1834
  return max;
1696
1835
  }
1697
1836
  function border(left, mid, right, widths) {
@@ -1713,43 +1852,86 @@ function renderRow(cells, widths, aligns) {
1713
1852
  return out;
1714
1853
  }
1715
1854
  function wrapCell(text, width) {
1716
- if (text.length <= width) return [text];
1855
+ if (strWidth(text) <= width) return [text];
1717
1856
  const out = [];
1718
1857
  const words = text.split(/(\s+)/);
1719
1858
  let cur = "";
1859
+ let curWidth = 0;
1720
1860
  for (const word of words) {
1721
1861
  if (!word) continue;
1722
- if (cur.length + word.length <= width) {
1862
+ const wordWidth = strWidth(word);
1863
+ if (curWidth + wordWidth <= width) {
1723
1864
  cur += word;
1865
+ curWidth += wordWidth;
1724
1866
  continue;
1725
1867
  }
1726
1868
  if (cur) {
1727
- out.push(cur.trimEnd());
1869
+ out.push(padVisual(cur, width));
1728
1870
  cur = "";
1871
+ curWidth = 0;
1729
1872
  }
1730
- if (word.length > width) {
1873
+ if (wordWidth > width) {
1731
1874
  let rest = word;
1732
- while (rest.length > width) {
1733
- out.push(rest.slice(0, width));
1734
- rest = rest.slice(width);
1875
+ let restWidth = wordWidth;
1876
+ while (restWidth > width) {
1877
+ let collected = "";
1878
+ let collectedWidth = 0;
1879
+ for (const cp of rest) {
1880
+ const cpWidth = strWidth(cp);
1881
+ if (collectedWidth + cpWidth > width) break;
1882
+ collected += cp;
1883
+ collectedWidth += cpWidth;
1884
+ }
1885
+ out.push(padVisual(collected, width));
1886
+ rest = rest.slice([...collected].join("").length);
1887
+ restWidth = strWidth(rest);
1735
1888
  }
1736
1889
  cur = rest;
1890
+ curWidth = strWidth(rest);
1737
1891
  } else if (!/^\s+$/.test(word)) {
1738
1892
  cur = word;
1893
+ curWidth = wordWidth;
1739
1894
  }
1740
1895
  }
1741
- if (cur) out.push(cur.trimEnd());
1896
+ if (cur) out.push(padVisual(cur, width));
1742
1897
  return out.length === 0 ? [""] : out;
1743
1898
  }
1899
+ function padVisual(text, targetWidth) {
1900
+ const w = strWidth(text);
1901
+ if (w >= targetWidth) {
1902
+ let taken = 0;
1903
+ let endIdx = 0;
1904
+ for (const cp of text) {
1905
+ const cpw = strWidth(cp);
1906
+ if (taken + cpw > targetWidth) break;
1907
+ taken += cpw;
1908
+ endIdx += [...cp].join("").length;
1909
+ }
1910
+ return text.slice(0, endIdx);
1911
+ }
1912
+ return text + " ".repeat(targetWidth - w);
1913
+ }
1744
1914
  function padCell(text, width, align) {
1745
- if (text.length >= width) return text.slice(0, width);
1746
- const pad = width - text.length;
1747
- if (align === "right") return " ".repeat(pad) + text;
1915
+ const visualLen = strWidth(text);
1916
+ let displayText = text;
1917
+ if (visualLen > width) {
1918
+ let takenWidth = 0;
1919
+ let endIdx = 0;
1920
+ for (const cp of text) {
1921
+ const cpWidth = strWidth(cp);
1922
+ if (takenWidth + cpWidth > width) break;
1923
+ takenWidth += cpWidth;
1924
+ endIdx += [...cp].join("").length;
1925
+ }
1926
+ displayText = text.slice(0, endIdx);
1927
+ }
1928
+ const pad = width - strWidth(displayText);
1929
+ if (align === "right") return " ".repeat(pad) + displayText;
1748
1930
  if (align === "center") {
1749
1931
  const l = Math.floor(pad / 2);
1750
- return " ".repeat(l) + text + " ".repeat(pad - l);
1932
+ return " ".repeat(l) + displayText + " ".repeat(pad - l);
1751
1933
  }
1752
- return text + " ".repeat(pad);
1934
+ return displayText + " ".repeat(pad);
1753
1935
  }
1754
1936
  function parseInline(text) {
1755
1937
  const tokens = [];
@@ -1857,7 +2039,7 @@ function MarkdownView({
1857
2039
  if (quote && line.startsWith(">")) {
1858
2040
  rows.push(
1859
2041
  /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
1860
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2502 " }),
2042
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " }),
1861
2043
  /* @__PURE__ */ jsx(InlineLine, { tokens: parseInline(quote[1] ?? ""), dim: true })
1862
2044
  ] }, `q${key++}`)
1863
2045
  );
@@ -1982,13 +2164,13 @@ function splitFencedBlocks(text) {
1982
2164
  function CodeBlock({
1983
2165
  code,
1984
2166
  lang,
1985
- termWidth
2167
+ contentWidth
1986
2168
  }) {
1987
2169
  let lines = code.replace(/\n+$/, "").split("\n");
1988
2170
  const hidden = Math.max(0, lines.length - MAX_CODE_LINES);
1989
2171
  if (hidden > 0) lines = lines.slice(0, MAX_CODE_LINES);
1990
2172
  const gutterW = String(lines.length).length;
1991
- const maxW = Math.max(20, Math.min(termWidth - 8 - gutterW - 1, 120));
2173
+ const maxW = Math.max(20, Math.min(contentWidth - 6 - gutterW - 1, 120));
1992
2174
  let carry = {};
1993
2175
  const rows = lines.map((raw) => {
1994
2176
  const display = raw.length > maxW ? `${raw.slice(0, maxW - 1)}\u2026` : raw;
@@ -1996,20 +2178,31 @@ function CodeBlock({
1996
2178
  carry = r.carry;
1997
2179
  return r.tokens;
1998
2180
  });
1999
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginLeft: 2, marginY: 0, borderStyle: "round", borderColor: theme.borderDefault, paddingX: 1, children: [
2000
- lang !== "plain" ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: lang }) : null,
2001
- rows.map((tokens, i) => (
2002
- // biome-ignore lint/suspicious/noArrayIndexKey: code lines are positional
2003
- /* @__PURE__ */ jsxs(Text, { children: [
2004
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${String(i + 1).padStart(gutterW, " ")} ` }),
2005
- tokens.length === 0 ? " " : tokens.map((t, j) => (
2006
- // biome-ignore lint/suspicious/noArrayIndexKey: token order is stable per line
2007
- /* @__PURE__ */ jsx(Text, { color: t.color, dimColor: t.dim, bold: t.bold, children: t.text }, j)
2008
- ))
2009
- ] }, i)
2010
- )),
2011
- hidden > 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, italic: true, children: `\u2026 +${hidden} more line${hidden === 1 ? "" : "s"}` }) : null
2012
- ] });
2181
+ return /* @__PURE__ */ jsxs(
2182
+ Box,
2183
+ {
2184
+ flexDirection: "column",
2185
+ marginLeft: 2,
2186
+ marginY: 0,
2187
+ borderStyle: "round",
2188
+ borderColor: theme.borderDefault,
2189
+ paddingX: 1,
2190
+ children: [
2191
+ lang !== "plain" ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: lang }) : null,
2192
+ rows.map((tokens, i) => (
2193
+ // biome-ignore lint/suspicious/noArrayIndexKey: code lines are positional
2194
+ /* @__PURE__ */ jsxs(Text, { children: [
2195
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${String(i + 1).padStart(gutterW, " ")} ` }),
2196
+ tokens.length === 0 ? " " : tokens.map((t, j) => (
2197
+ // biome-ignore lint/suspicious/noArrayIndexKey: token order is stable per line
2198
+ /* @__PURE__ */ jsx(Text, { color: t.color, dimColor: t.dim, bold: t.bold, children: t.text }, j)
2199
+ ))
2200
+ ] }, i)
2201
+ )),
2202
+ hidden > 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, italic: true, children: `\u2026 +${hidden} more line${hidden === 1 ? "" : "s"}` }) : null
2203
+ ]
2204
+ }
2205
+ );
2013
2206
  }
2014
2207
  function AssistantBody({
2015
2208
  text,
@@ -2021,7 +2214,7 @@ function AssistantBody({
2021
2214
  return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: segments.map(
2022
2215
  (seg, i) => seg.type === "code" ? (
2023
2216
  // biome-ignore lint/suspicious/noArrayIndexKey: segment order is stable
2024
- /* @__PURE__ */ jsx(CodeBlock, { code: seg.text, lang: seg.lang ?? "plain", termWidth }, i)
2217
+ /* @__PURE__ */ jsx(CodeBlock, { code: seg.text, lang: seg.lang ?? "plain", contentWidth: inner }, i)
2025
2218
  ) : (
2026
2219
  // biome-ignore lint/suspicious/noArrayIndexKey: segment order is stable
2027
2220
  /* @__PURE__ */ jsx(MarkdownView, { text: seg.text, termWidth: inner }, i)
@@ -2150,14 +2343,7 @@ var Entry = React4.memo(function Entry2({
2150
2343
  paddingLeft: 1,
2151
2344
  children: [
2152
2345
  /* @__PURE__ */ jsx(Box, { flexDirection: "row", children: /* @__PURE__ */ jsx(Text, { bold: true, color: theme.assistant, children: "ASSISTANT" }) }),
2153
- /* @__PURE__ */ jsx(
2154
- AssistantBody,
2155
- {
2156
- text: entry.text,
2157
- termWidth,
2158
- contentWidth
2159
- }
2160
- )
2346
+ /* @__PURE__ */ jsx(AssistantBody, { text: entry.text, termWidth, contentWidth })
2161
2347
  ]
2162
2348
  }
2163
2349
  );
@@ -3272,8 +3458,10 @@ function fmtRecentTool(tool) {
3272
3458
  const name = tool.name.length > 18 ? `${tool.name.slice(0, 17)}...` : tool.name;
3273
3459
  const parts = [status, name];
3274
3460
  if (typeof tool.durationMs === "number") parts.push(fmtElapsed2(tool.durationMs));
3275
- if (typeof tool.outputBytes === "number" && tool.outputBytes > 0) parts.push(fmtBytes2(tool.outputBytes));
3276
- if (typeof tool.outputLines === "number" && tool.outputLines > 0) parts.push(`${tool.outputLines}L`);
3461
+ if (typeof tool.outputBytes === "number" && tool.outputBytes > 0)
3462
+ parts.push(fmtBytes2(tool.outputBytes));
3463
+ if (typeof tool.outputLines === "number" && tool.outputLines > 0)
3464
+ parts.push(`${tool.outputLines}L`);
3277
3465
  return parts.join(" ");
3278
3466
  }
3279
3467
  function fmtRecentMessage(message) {
@@ -3406,7 +3594,9 @@ function PhaseMonitor({
3406
3594
  if (key.escape) onClose();
3407
3595
  });
3408
3596
  const phaseList = Object.values(phases);
3409
- const running = phaseList.filter((p) => runningPhaseIds.includes(Object.keys(phases).find((k) => phases[k] === p) ?? ""));
3597
+ const running = phaseList.filter(
3598
+ (p) => runningPhaseIds.includes(Object.keys(phases).find((k) => phases[k] === p) ?? "")
3599
+ );
3410
3600
  const done = phaseList.filter((p) => p.status === "completed" || p.status === "skipped");
3411
3601
  const failed = phaseList.filter((p) => p.status === "failed");
3412
3602
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
@@ -3617,7 +3807,7 @@ function ScrollableHistory({
3617
3807
  lastReported.current = height;
3618
3808
  onMeasure(height);
3619
3809
  }
3620
- });
3810
+ }, [onMeasure]);
3621
3811
  const vp = Math.max(1, viewportRows);
3622
3812
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "row", children: [
3623
3813
  /* @__PURE__ */ jsx(
@@ -3853,7 +4043,9 @@ function WorktreePanel({
3853
4043
  }) {
3854
4044
  const list = Object.values(worktrees);
3855
4045
  if (list.length === 0) return null;
3856
- const active = list.filter((w) => w.status === "active" || w.status === "committing" || w.status === "merging").length;
4046
+ const active = list.filter(
4047
+ (w) => w.status === "active" || w.status === "committing" || w.status === "merging"
4048
+ ).length;
3857
4049
  const merged = list.filter((w) => w.status === "merged").length;
3858
4050
  const failed = list.filter((w) => w.status === "failed" || w.status === "needs-review").length;
3859
4051
  return /* @__PURE__ */ jsxs(
@@ -3897,7 +4089,8 @@ function WorktreePanel({
3897
4089
  /* @__PURE__ */ jsx(Text, { children: w.branch.replace(/^wstack\/ap\//, "").slice(0, 18).padEnd(18) }),
3898
4090
  /* @__PURE__ */ jsx(Text, { dimColor: true, children: w.ownerLabel.slice(0, 12) }),
3899
4091
  conflict ? /* @__PURE__ */ jsx(Text, { color: "magenta", children: " CONFLICT" }) : /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
3900
- " +",
4092
+ " ",
4093
+ "+",
3901
4094
  w.insertions,
3902
4095
  "/-",
3903
4096
  w.deletions,
@@ -4061,13 +4254,18 @@ function createKillSlashCommand() {
4061
4254
  if (sub === "all") {
4062
4255
  const pids = getProcessRegistry().killAll();
4063
4256
  if (pids.length === 0) return { message: "No processes to kill." };
4064
- return { message: `Killed ${pids.length} process${pids.length === 1 ? "" : "es"}: ${pids.join(", ")}` };
4257
+ return {
4258
+ message: `Killed ${pids.length} process${pids.length === 1 ? "" : "es"}: ${pids.join(", ")}`
4259
+ };
4065
4260
  }
4066
4261
  if (sub === "force") {
4067
4262
  getProcessRegistry().forceBreakerOpen();
4068
4263
  const pids = getProcessRegistry().killAll({ force: true });
4069
- if (pids.length === 0) return { message: "Circuit breaker forced open. No processes to kill." };
4070
- return { message: `Force-killed ${pids.length} process${pids.length === 1 ? "" : "es"}: ${pids.join(", ")}` };
4264
+ if (pids.length === 0)
4265
+ return { message: "Circuit breaker forced open. No processes to kill." };
4266
+ return {
4267
+ message: `Force-killed ${pids.length} process${pids.length === 1 ? "" : "es"}: ${pids.join(", ")}`
4268
+ };
4071
4269
  }
4072
4270
  if (sub === "reset") {
4073
4271
  getProcessRegistry().forceBreakerReset();
@@ -5236,6 +5434,7 @@ function App({
5236
5434
  switchProviderAndModel,
5237
5435
  getSettings,
5238
5436
  saveSettings,
5437
+ predictNext,
5239
5438
  switchAutonomy,
5240
5439
  effectiveMaxContext,
5241
5440
  onExit,
@@ -5261,8 +5460,12 @@ function App({
5261
5460
  const [hiddenItems, setHiddenItems] = useState(statuslineHiddenItems);
5262
5461
  const { stdout } = useStdout();
5263
5462
  const [termRows, setTermRows] = useState(stdout?.rows ?? 24);
5463
+ const [termCols, setTermCols] = useState(stdout?.columns ?? 80);
5264
5464
  useEffect(() => {
5265
- const onResize = () => setTermRows(process.stdout.rows ?? 24);
5465
+ const onResize = () => {
5466
+ setTermRows(process.stdout.rows ?? 24);
5467
+ setTermCols(process.stdout.columns ?? 80);
5468
+ };
5266
5469
  process.stdout.on("resize", onResize);
5267
5470
  return () => {
5268
5471
  process.stdout.off("resize", onResize);
@@ -5412,7 +5615,7 @@ function App({
5412
5615
  if (vp !== s2.viewportRows) {
5413
5616
  dispatch({ type: "setViewportRows", rows: vp });
5414
5617
  }
5415
- });
5618
+ }, [managedLive, termRows]);
5416
5619
  const handleKeyRef = useRef(null);
5417
5620
  const pendingClickConfirmRef = useRef(null);
5418
5621
  const openModelPickerRef = useRef(null);
@@ -5460,7 +5663,7 @@ function App({
5460
5663
  }
5461
5664
  return;
5462
5665
  }
5463
- const cols = process.stdout.columns ?? 0;
5666
+ const cols = termCols || 80;
5464
5667
  const onScrollbar = cols > 0 && ev.x >= cols - 2 && ev.y >= 1 && ev.y <= rows;
5465
5668
  if (onScrollbar && s2.totalLines > rows) {
5466
5669
  scrollbarDragRef.current = true;
@@ -5548,7 +5751,7 @@ function App({
5548
5751
  if (!picker || picker.count === 0) {
5549
5752
  const inputDisabled = s2.status === "aborting" && !s2.steeringPending;
5550
5753
  if (!inputDisabled) {
5551
- const cols = process.stdout.columns ?? 80;
5754
+ const cols = termCols || 80;
5552
5755
  const inputTop = s2.viewportRows + affordance + liveStripRowsRef.current + 1;
5553
5756
  const inputRows = layoutInputRows(INPUT_PROMPT, s2.buffer, s2.cursor, cols).length;
5554
5757
  const rowIdx = ev.y - inputTop;
@@ -5591,7 +5794,8 @@ function App({
5591
5794
  }
5592
5795
  },
5593
5796
  // dispatch is stable (useReducer); refs are mutable — no reactive deps.
5594
- []
5797
+ // termCols is stable (useState + resize effect).
5798
+ [termCols]
5595
5799
  );
5596
5800
  useEffect(() => {
5597
5801
  if (!subscribeMouse) return;
@@ -5760,6 +5964,12 @@ function App({
5760
5964
  }, [agent.ctx.meta]);
5761
5965
  const prevAnyOverlayOpen = useRef(false);
5762
5966
  const prevEntriesCount = useRef(0);
5967
+ const eraseLiveRegion = useCallback(() => {
5968
+ try {
5969
+ process.stdout.write("\x1B[J");
5970
+ } catch {
5971
+ }
5972
+ }, []);
5763
5973
  useEffect(() => {
5764
5974
  const anyOpenNow = state.picker.open || state.slashPicker.open || state.modelPicker.open || state.autonomyPicker.open || state.settingsPicker.open || state.confirmQueue.length > 0;
5765
5975
  const overlayClosed = prevAnyOverlayOpen.current && !anyOpenNow;
@@ -5767,10 +5977,7 @@ function App({
5767
5977
  prevAnyOverlayOpen.current = anyOpenNow;
5768
5978
  prevEntriesCount.current = state.entries.length;
5769
5979
  if (overlayClosed || newEntryCommitted) {
5770
- try {
5771
- process.stdout.write("\x1B[J");
5772
- } catch {
5773
- }
5980
+ eraseLiveRegion();
5774
5981
  }
5775
5982
  }, [
5776
5983
  state.picker.open,
@@ -5779,8 +5986,16 @@ function App({
5779
5986
  state.autonomyPicker.open,
5780
5987
  state.settingsPicker.open,
5781
5988
  state.confirmQueue.length,
5782
- state.entries.length
5989
+ state.entries.length,
5990
+ eraseLiveRegion
5783
5991
  ]);
5992
+ useEffect(() => {
5993
+ const handleResize = () => eraseLiveRegion();
5994
+ process.stdout.on("resize", handleResize);
5995
+ return () => {
5996
+ process.stdout.off("resize", handleResize);
5997
+ };
5998
+ }, [eraseLiveRegion]);
5784
5999
  useEffect(() => {
5785
6000
  const detected = detectAtToken(state.buffer, state.cursor);
5786
6001
  if (!detected) {
@@ -7754,6 +7969,22 @@ function App({
7754
7969
  }
7755
7970
  });
7756
7971
  }
7972
+ if (result.status === "done" && predictNext) {
7973
+ try {
7974
+ const userRequest = blocks.filter((b) => b.type === "text").map((b) => b.text).join(" ").trim();
7975
+ const predictions = await predictNext({
7976
+ userRequest,
7977
+ assistantSummary: result.finalText ?? ""
7978
+ });
7979
+ if (predictions.length > 0) {
7980
+ const text = ["\u21B3 likely next:", ...predictions.map((p, i) => ` ${i + 1}. ${p}`)].join(
7981
+ "\n"
7982
+ );
7983
+ dispatch({ type: "addEntry", entry: { kind: "turn-summary", text } });
7984
+ }
7985
+ } catch {
7986
+ }
7987
+ }
7757
7988
  } catch (err) {
7758
7989
  dispatch({
7759
7990
  type: "addEntry",
@@ -8397,8 +8628,48 @@ async function runTui(opts) {
8397
8628
  let inkStdin = stdin;
8398
8629
  let detachMouse = null;
8399
8630
  if (useMouse) {
8400
- const proxy = new PassThrough();
8401
- const p = proxy;
8631
+ class KeyboardReadable extends Readable {
8632
+ pendingChunks = [];
8633
+ // eslint-disable-next-line no-useless-constructor
8634
+ constructor() {
8635
+ super({ encoding: "utf8", highWaterMark: 64 * 1024 });
8636
+ }
8637
+ _read(_size) {
8638
+ this.flushPending();
8639
+ }
8640
+ flushPending() {
8641
+ while (this.pendingChunks.length > 0) {
8642
+ const chunk = this.pendingChunks[0];
8643
+ const ok = this.push(chunk);
8644
+ this.pendingChunks.shift();
8645
+ if (!ok) {
8646
+ break;
8647
+ }
8648
+ }
8649
+ }
8650
+ /** Called by the stdin data handler when keyboard bytes are available. */
8651
+ doPush(chunk) {
8652
+ if (chunk.length === 0) return;
8653
+ const ok = this.push(chunk);
8654
+ if (ok) {
8655
+ if (this.pendingChunks.length > 0) {
8656
+ this.flushPending();
8657
+ }
8658
+ } else {
8659
+ if (this.pendingChunks.length >= 100) {
8660
+ this.pendingChunks.shift();
8661
+ }
8662
+ this.pendingChunks.push(chunk);
8663
+ }
8664
+ }
8665
+ /** Called on shutdown so the stream closes cleanly. */
8666
+ doEnd() {
8667
+ this.pendingChunks = [];
8668
+ this.push(null);
8669
+ }
8670
+ }
8671
+ const keyboardStream = new KeyboardReadable();
8672
+ const p = keyboardStream;
8402
8673
  p.isTTY = true;
8403
8674
  p.setRawMode = (mode) => {
8404
8675
  try {
@@ -8429,10 +8700,13 @@ async function runTui(opts) {
8429
8700
  }
8430
8701
  }
8431
8702
  const rest = stripSgrMouse(chunk);
8432
- if (rest.length > 0) proxy.write(rest);
8703
+ keyboardStream.doPush(rest);
8433
8704
  };
8434
8705
  stdin.on("data", onData);
8435
- detachMouse = () => stdin.off("data", onData);
8706
+ detachMouse = () => {
8707
+ stdin.off("data", onData);
8708
+ keyboardStream.doEnd();
8709
+ };
8436
8710
  inkStdin = p;
8437
8711
  }
8438
8712
  const subscribeMouse = useMouse ? (fn) => {
@@ -8551,6 +8825,7 @@ async function runTui(opts) {
8551
8825
  projectRoot: opts.projectRoot,
8552
8826
  getSettings: opts.getSettings,
8553
8827
  saveSettings: opts.saveSettings,
8828
+ predictNext: opts.predictNext,
8554
8829
  mouse: useMouse,
8555
8830
  subscribeMouse,
8556
8831
  // Managed viewport (in-app scroll + collapsibility) follows