@deepseek-harness-tui/dsh-tui 0.10.1 → 0.10.2

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.
Files changed (74) hide show
  1. package/README.md +5 -146
  2. package/lib/types/adapter/ports/channel-session.d.ts +3 -1
  3. package/lib/types/adapter/ports/channel-session.d.ts.map +1 -1
  4. package/lib/types/components/Chat/JobCard.d.ts.map +1 -1
  5. package/lib/types/components/Chat/JobCard.js +8 -1
  6. package/lib/types/components/Markdown.d.ts +2 -2
  7. package/lib/types/components/Markdown.d.ts.map +1 -1
  8. package/lib/types/components/Markdown.js +28 -3
  9. package/lib/types/components/MessageList.d.ts.map +1 -1
  10. package/lib/types/components/MessageList.js +10 -9
  11. package/lib/types/components/StreamingMarkdown.d.ts.map +1 -1
  12. package/lib/types/components/StreamingMarkdown.js +61 -9
  13. package/lib/types/dsh-adapter/channel/binding-events.d.ts +5 -0
  14. package/lib/types/dsh-adapter/channel/binding-events.d.ts.map +1 -1
  15. package/lib/types/dsh-adapter/channel/binding-events.js +12 -2
  16. package/lib/types/dsh-adapter/channel/input-delivery.d.ts +5 -2
  17. package/lib/types/dsh-adapter/channel/input-delivery.d.ts.map +1 -1
  18. package/lib/types/dsh-adapter/channel/input-delivery.js +71 -7
  19. package/lib/types/dsh-adapter/channel/permissions.d.ts.map +1 -1
  20. package/lib/types/dsh-adapter/channel/permissions.js +10 -7
  21. package/lib/types/dsh-adapter/channel/session-rewind.d.ts.map +1 -1
  22. package/lib/types/dsh-adapter/channel/session-rewind.js +9 -2
  23. package/lib/types/dsh-adapter/channel/session-tree-actions.d.ts.map +1 -1
  24. package/lib/types/dsh-adapter/channel/session-tree-actions.js +9 -3
  25. package/lib/types/dsh-adapter/channel/skill-catalog.d.ts +8 -2
  26. package/lib/types/dsh-adapter/channel/skill-catalog.d.ts.map +1 -1
  27. package/lib/types/dsh-adapter/channel/skill-catalog.js +49 -17
  28. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  29. package/lib/types/dsh-adapter/channel.js +5 -2
  30. package/lib/types/dsh-adapter/compat/liveSession.d.ts +5 -3
  31. package/lib/types/dsh-adapter/compat/liveSession.d.ts.map +1 -1
  32. package/lib/types/dsh-adapter/compat/liveSession.js +5 -3
  33. package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -1
  34. package/lib/types/dsh-adapter/sessions/digest.js +40 -19
  35. package/lib/types/dsh-adapter/sessions/frames.js +2 -0
  36. package/lib/types/dsh-adapter/sessions/list.js +3 -3
  37. package/lib/types/dsh-adapter/sessions/store.d.ts +2 -2
  38. package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -1
  39. package/lib/types/dsh-adapter/sessions/store.js +12 -11
  40. package/lib/types/dsh-adapter/sessions/types.d.ts +2 -0
  41. package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -1
  42. package/lib/types/ink/components/ScrollBox.d.ts.map +1 -1
  43. package/lib/types/ink/components/ScrollBox.js +30 -33
  44. package/lib/types/ink/dom.d.ts.map +1 -1
  45. package/lib/types/ink/dom.js +32 -64
  46. package/lib/types/ink/get-max-width.d.ts +7 -15
  47. package/lib/types/ink/get-max-width.d.ts.map +1 -1
  48. package/lib/types/ink/get-max-width.js +7 -20
  49. package/lib/types/ink/ink.d.ts +1 -0
  50. package/lib/types/ink/ink.d.ts.map +1 -1
  51. package/lib/types/ink/ink.js +22 -3
  52. package/lib/types/ink/layout/node.d.ts +2 -0
  53. package/lib/types/ink/layout/node.d.ts.map +1 -1
  54. package/lib/types/ink/layout/yoga.d.ts +1 -0
  55. package/lib/types/ink/layout/yoga.d.ts.map +1 -1
  56. package/lib/types/ink/layout/yoga.js +3 -0
  57. package/lib/types/ink/node-cache.d.ts +11 -0
  58. package/lib/types/ink/node-cache.d.ts.map +1 -1
  59. package/lib/types/ink/node-cache.js +3 -0
  60. package/lib/types/ink/output.d.ts +6 -1
  61. package/lib/types/ink/output.d.ts.map +1 -1
  62. package/lib/types/ink/output.js +55 -65
  63. package/lib/types/ink/render-node-to-output.d.ts.map +1 -1
  64. package/lib/types/ink/render-node-to-output.js +92 -67
  65. package/lib/types/ink/tabstops.d.ts +3 -2
  66. package/lib/types/ink/tabstops.d.ts.map +1 -1
  67. package/lib/types/ink/tabstops.js +5 -4
  68. package/lib/types/ink/wrap-text.js +2 -2
  69. package/lib/types/native-ts/yoga-layout/index.d.ts +8 -3
  70. package/lib/types/native-ts/yoga-layout/index.d.ts.map +1 -1
  71. package/lib/types/native-ts/yoga-layout/index.js +23 -8
  72. package/lib/types/screens/Chat.d.ts.map +1 -1
  73. package/lib/types/screens/Chat.js +20 -8
  74. package/package.json +3 -2
@@ -25,7 +25,7 @@ import { createHash } from 'node:crypto';
25
25
  import { open } from 'node:fs/promises';
26
26
  import { basename } from 'node:path';
27
27
  import { scheduler } from 'node:timers/promises';
28
- import { decodeFrame, decodeFrames, decodeTail, readWindow, resyncFrames, walkFrames } from './frames.js';
28
+ import { decodeFrame, decodeTail, readWindow, resyncFrames, walkFrames } from './frames.js';
29
29
  /** Head window budget. Eight times the measured worst-case prompt offset. */
30
30
  export const HEAD_WINDOW_BYTES = 64 * 1024;
31
31
  /** Head frame ceiling — a cost bound independent of how the bytes compress. */
@@ -71,14 +71,14 @@ function isHumanSource(source) {
71
71
  return false;
72
72
  return source['kind'] === 'user';
73
73
  }
74
- /** The human prompt carried by one log line, in either of its two forms. */
74
+ /** A human message is conversation evidence even when it has no title text. */
75
75
  function humanPrompt(line) {
76
76
  const data = line['data'];
77
77
  if (data === null || typeof data !== 'object')
78
78
  return undefined;
79
79
  const record = data;
80
80
  if (line['type'] === 'user/message') {
81
- return isHumanSource(record['source']) ? textOfContent(record['content']) : undefined;
81
+ return isHumanSource(record['source']) ? { text: textOfContent(record['content']) } : undefined;
82
82
  }
83
83
  // The inbox splice precedes the durable user/message and reaches the log
84
84
  // several frames earlier, which is what keeps the head window small.
@@ -86,16 +86,20 @@ function humanPrompt(line) {
86
86
  const inserted = record['inserted'];
87
87
  if (!Array.isArray(inserted))
88
88
  return undefined;
89
+ let found = false;
89
90
  for (const message of inserted) {
90
91
  if (message === null || typeof message !== 'object')
91
92
  continue;
92
93
  const entry = message;
93
94
  if (entry['role'] !== 'user' || !isHumanSource(entry['source']))
94
95
  continue;
96
+ found = true;
95
97
  const text = textOfContent(entry['content']);
96
98
  if (text !== undefined)
97
- return text;
99
+ return { text };
98
100
  }
101
+ if (found)
102
+ return { text: undefined };
99
103
  }
100
104
  return undefined;
101
105
  }
@@ -152,24 +156,36 @@ function timeOf(line) {
152
156
  export function digestSession(path, cwd) {
153
157
  const head = readWindow(path, HEAD_WINDOW_BYTES);
154
158
  if (head === undefined) {
155
- return { title: undefined, hasPrompt: false, model: undefined, label: undefined };
159
+ return { title: undefined, hasPrompt: true, model: undefined, label: undefined };
156
160
  }
157
- const headLines = decodeFrames(head.buffer, walkFrames(head.buffer, 0, HEAD_MAX_FRAMES));
161
+ const headFrames = walkFrames(head.buffer, 0, HEAD_MAX_FRAMES);
162
+ const headLines = [];
163
+ let completeHead = head.whole && headFrames.at(-1)?.end === head.buffer.length;
164
+ for (const frame of headFrames) {
165
+ const lines = decodeFrame(head.buffer, frame);
166
+ if (lines === undefined)
167
+ completeHead = false;
168
+ else
169
+ headLines.push(...lines);
170
+ }
171
+ completeHead &&= headLines[0]?.['type'] === 'session';
158
172
  let prompt;
173
+ let hasHumanMessage = false;
159
174
  let headTitle;
160
175
  let label;
161
176
  for (const line of headLines) {
162
- prompt ??= humanPrompt(line);
177
+ const human = humanPrompt(line);
178
+ hasHumanMessage ||= human !== undefined;
179
+ prompt ??= human?.text;
163
180
  headTitle ??= titleOf(line);
164
181
  label ??= labelOf(line);
165
182
  }
166
- // Absence of a prompt only means "empty" when the window actually saw the
167
- // whole log. A log too large for the window has a conversation in it by
168
- // construction, and erring toward listing it is the safe direction: hiding
169
- // a real session is a defect, showing a boot artifact is a nuisance.
170
- const hasPrompt = prompt !== undefined || !head.whole;
171
- // A head window that already covered the whole log IS the tail.
172
- const tail = head.whole ? undefined : readWindow(path, TAIL_WINDOW_BYTES, true);
183
+ // The byte window, frame limit, decoding and parsing must ALL cover the
184
+ // log before absence proves emptiness. Unknown is visible, never eligible
185
+ // for destructive cleanup; title text is not required for image-only input.
186
+ const hasPrompt = hasHumanMessage || !completeHead;
187
+ // Only a completely decoded head can stand in for the tail.
188
+ const tail = completeHead ? undefined : readWindow(path, TAIL_WINDOW_BYTES, true);
173
189
  const tailLines = tail === undefined ? headLines : decodeTail(tail);
174
190
  let tailTitle;
175
191
  let model;
@@ -189,7 +205,7 @@ export function digestSession(path, cwd) {
189
205
  hasPrompt,
190
206
  model,
191
207
  label,
192
- ...(!head.whole && tailTitle === undefined ? {} : { titleComplete: true }),
208
+ ...(!completeHead && tailTitle === undefined ? {} : { titleComplete: true }),
193
209
  };
194
210
  }
195
211
  /** Read exactly one stable range from an already-open snapshot. */
@@ -341,6 +357,7 @@ async function recoverFirstPrompt(path, bytes, signal) {
341
357
  }
342
358
  try {
343
359
  let position = 0;
360
+ let hasPrompt = false;
344
361
  while (position < bytes) {
345
362
  signal?.throwIfAborted();
346
363
  const page = await forwardPage(handle, position, bytes, signal);
@@ -350,10 +367,14 @@ async function recoverFirstPrompt(path, bytes, signal) {
350
367
  const lines = decodeFrame(page.buffer, frame);
351
368
  if (lines === undefined)
352
369
  return { prompt: undefined, complete: false };
370
+ if (position === 0 && frame.start === 0 && lines[0]?.['type'] !== 'session') {
371
+ return { prompt: undefined, complete: false };
372
+ }
353
373
  for (const line of lines) {
354
374
  const prompt = humanPrompt(line);
355
- if (prompt !== undefined)
356
- return { prompt, complete: true };
375
+ hasPrompt ||= prompt !== undefined;
376
+ if (prompt?.text !== undefined)
377
+ return { prompt: prompt.text, complete: true, hasPrompt: true };
357
378
  }
358
379
  }
359
380
  const consumed = page.frames[page.frames.length - 1].end;
@@ -362,7 +383,7 @@ async function recoverFirstPrompt(path, bytes, signal) {
362
383
  position += consumed;
363
384
  await scheduler.yield();
364
385
  }
365
- return { prompt: undefined, complete: true };
386
+ return { prompt: undefined, complete: bytes > 0, ...(bytes > 0 ? { hasPrompt } : {}) };
366
387
  }
367
388
  finally {
368
389
  await handle.close().catch(() => { });
@@ -381,7 +402,7 @@ export async function recoverSessionTitle(path, bytes, signal) {
381
402
  return {
382
403
  title: opening.prompt === undefined ? undefined : { text: opening.prompt, source: 'prompt' },
383
404
  complete: opening.complete,
384
- ...(opening.complete ? { hasPrompt: opening.prompt !== undefined } : {}),
405
+ ...(opening.hasPrompt === undefined ? {} : { hasPrompt: opening.hasPrompt }),
385
406
  };
386
407
  }
387
408
  /** Hash the previous EOF neighborhood before carrying title evidence forward. */
@@ -174,6 +174,8 @@ export function decodeFrame(buffer, frame) {
174
174
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
175
175
  lines.push(parsed);
176
176
  }
177
+ else
178
+ return undefined;
177
179
  }
178
180
  catch {
179
181
  return undefined;
@@ -157,13 +157,15 @@ export async function listSummaries(source, signal) {
157
157
  else if (path !== undefined && token !== undefined) {
158
158
  const digest = digestSession(path, header.cwd ?? '');
159
159
  let title = digest.title;
160
- let hasPrompt = digest.hasPrompt;
160
+ const hasPrompt = digest.hasPrompt;
161
161
  let titleComplete = digest.titleComplete === true;
162
162
  const previous = cached?.derived;
163
163
  // Across an append-only revision, validate the old EOF neighborhood then
164
164
  // scan only the new frames. This preserves an older authoritative title
165
165
  // without trusting file size alone, and still observes a newly appended
166
166
  // rename even when later output pushed it outside the cheap tail window.
167
+ // Only title evidence carries forward: a formerly empty session can
168
+ // have acquired its first human message in the appended suffix.
167
169
  if (!titleComplete &&
168
170
  previous !== undefined &&
169
171
  facts !== undefined &&
@@ -177,7 +179,6 @@ export async function listSummaries(source, signal) {
177
179
  title = previous.title.length === 0
178
180
  ? undefined
179
181
  : { text: previous.title, source: previous.titleSource };
180
- hasPrompt = previous.hasPrompt;
181
182
  titleComplete = true;
182
183
  }
183
184
  else if (facts.bytes > previous.bytes) {
@@ -190,7 +191,6 @@ export async function listSummaries(source, signal) {
190
191
  ? undefined
191
192
  : { text: previous.title, source: previous.titleSource };
192
193
  }
193
- hasPrompt = previous.hasPrompt;
194
194
  titleComplete = true;
195
195
  }
196
196
  }
@@ -26,8 +26,8 @@ export interface IndexEntry {
26
26
  export type SessionIndex = Map<string, IndexEntry>;
27
27
  /**
28
28
  * Load the cache.
29
- * @returns The parsed index; an unreadable, malformed, or stale-schema file
30
- * yields an empty one, which costs a rebuild and never an error.
29
+ * @returns The parsed index; old derived facts are discarded for a rebuild.
30
+ * Version 2 retains branch notes; unrecognized files yield an empty index.
31
31
  */
32
32
  export declare function readIndex(): SessionIndex;
33
33
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/store.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAW7C,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,wFAAwF;IACxF,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAA;IACrC,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,8EAA8E;IAC9E,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAA;IAC/B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CACnC;AAED,gFAAgF;AAChF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,SAAS,CAAA;IAC1C,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;CACpC;AAED,2CAA2C;AAC3C,MAAM,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;AAsDlD;;;;GAIG;AACH,wBAAgB,SAAS,IAAI,YAAY,CAkBxC;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAwBpD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAO9E"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/store.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAW7C,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,wFAAwF;IACxF,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAA;IACrC,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,8EAA8E;IAC9E,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAA;IAC/B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CACnC;AAED,gFAAgF;AAChF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,SAAS,CAAA;IAC1C,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;CACpC;AAED,2CAA2C;AAC3C,MAAM,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;AAuDlD;;;;GAIG;AACH,wBAAgB,SAAS,IAAI,YAAY,CAkBxC;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAwBpD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAO9E"}
@@ -31,11 +31,11 @@ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node
31
31
  import { join } from 'node:path';
32
32
  import { DATA_DIR } from '../../utils/paths.js';
33
33
  /**
34
- * Bumped when an entry's shape changes. A mismatch drops the whole file
35
- * rather than migrating it — re-deriving is cheap and bounded, whereas a
36
- * migration path is code that runs once and is never exercised again.
34
+ * Bumped when derived facts change shape or meaning. Version 2 could cache
35
+ * incomplete reads as empty: discard those derivations, retaining only the
36
+ * branch notes that cannot be recovered from the session log.
37
37
  */
38
- const SCHEMA_VERSION = 2;
38
+ const SCHEMA_VERSION = 3;
39
39
  const INDEX_FILE = join(DATA_DIR, 'session-index.json');
40
40
  /** A title source as written to disk, or undefined for anything unexpected. */
41
41
  function readTitleSource(value) {
@@ -44,12 +44,12 @@ function readTitleSource(value) {
44
44
  : undefined;
45
45
  }
46
46
  /** Narrow one persisted entry; an unrecognizable record is simply absent. */
47
- function readEntry(value) {
47
+ function readEntry(value, derivedValid) {
48
48
  if (value === null || typeof value !== 'object')
49
49
  return undefined;
50
50
  const record = value;
51
51
  const branch = typeof record['branch'] === 'string' ? record['branch'] : undefined;
52
- const raw = record['derived'];
52
+ const raw = derivedValid ? record['derived'] : undefined;
53
53
  if (raw === null || typeof raw !== 'object')
54
54
  return { derived: undefined, branch };
55
55
  const derived = raw;
@@ -68,7 +68,8 @@ function readEntry(value) {
68
68
  (anchor !== undefined && typeof anchor !== 'string') ||
69
69
  typeof title !== 'string' ||
70
70
  titleSource === undefined ||
71
- typeof titleComplete !== 'boolean') {
71
+ typeof titleComplete !== 'boolean' ||
72
+ typeof derived['hasPrompt'] !== 'boolean') {
72
73
  return { derived: undefined, branch };
73
74
  }
74
75
  return {
@@ -89,8 +90,8 @@ function readEntry(value) {
89
90
  }
90
91
  /**
91
92
  * Load the cache.
92
- * @returns The parsed index; an unreadable, malformed, or stale-schema file
93
- * yields an empty one, which costs a rebuild and never an error.
93
+ * @returns The parsed index; old derived facts are discarded for a rebuild.
94
+ * Version 2 retains branch notes; unrecognized files yield an empty index.
94
95
  */
95
96
  export function readIndex() {
96
97
  const index = new Map();
@@ -104,13 +105,13 @@ export function readIndex() {
104
105
  if (parsed === null || typeof parsed !== 'object')
105
106
  return index;
106
107
  const file = parsed;
107
- if (file['version'] !== SCHEMA_VERSION)
108
+ if (file['version'] !== SCHEMA_VERSION && file['version'] !== 2)
108
109
  return index;
109
110
  const entries = file['entries'];
110
111
  if (entries === null || typeof entries !== 'object')
111
112
  return index;
112
113
  for (const [id, value] of Object.entries(entries)) {
113
- const entry = readEntry(value);
114
+ const entry = readEntry(value, file['version'] === SCHEMA_VERSION);
114
115
  if (entry !== undefined)
115
116
  index.set(id, entry);
116
117
  }
@@ -9,6 +9,8 @@ export type { SessionSummary, SessionKind, SessionTitle, TitleSource, PreviewEnt
9
9
  */
10
10
  export interface SessionDigest {
11
11
  readonly title: SessionTitle | undefined;
12
+ /** False only after a complete successful scan proves no human message;
13
+ * true also keeps inconclusive reads visible and out of empty cleanup. */
12
14
  readonly hasPrompt: boolean;
13
15
  readonly model: string | undefined;
14
16
  readonly label: string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA+B,YAAY,EAA6B,MAAM,wCAAwC,CAAA;AAClI,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,wCAAwC,CAAA;AAGlI;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,CAAA;IACxC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC;mEAC+D;IAC/D,QAAQ,CAAC,aAAa,CAAC,EAAE,IAAI,CAAA;CAC9B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA+B,YAAY,EAA6B,MAAM,wCAAwC,CAAA;AAClI,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,wCAAwC,CAAA;AAGlI;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,SAAS,CAAA;IACxC;8EAC0E;IAC1E,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC;mEAC+D;IAC/D,QAAQ,CAAC,aAAa,CAAC,EAAE,IAAI,CAAA;CAC9B"}
@@ -1 +1 @@
1
- {"version":3,"file":"ScrollBox.d.ts","sourceRoot":"","sources":["../../../../src/ink/components/ScrollBox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,KAAK,iBAAiB,EAAE,KAAK,GAAG,EAAsD,MAAM,OAAO,CAAC;AACpH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAGxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAM5C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3C,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B;;;;;;OAMG;IACH,eAAe,EAAE,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,MAAM,CAAC;IAC3B,eAAe,EAAE,MAAM,MAAM,CAAC;IAC9B,eAAe,EAAE,MAAM,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,MAAM,CAAC;IACnC,iBAAiB,EAAE,MAAM,MAAM,CAAC;IAChC;;;OAGG;IACH,cAAc,EAAE,MAAM,MAAM,CAAC;IAC7B;;;;;;OAMG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB;;;;;OAKG;IACH,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;IAChD;;;;;;;OAOG;IACH,cAAc,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;CAC5E,CAAC;AACF,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,GAAG,UAAU,GAAG,WAAW,GAAG,WAAW,CAAC,GAAG;IACjG,GAAG,CAAC,EAAE,GAAG,CAAC,eAAe,CAAC,CAAC;IAC3B;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF;;;;;;;;;GASG;AACH,iBAAS,SAAS,CAAC,EACjB,QAAQ,EACR,GAAG,EACH,YAAY,EACZ,GAAG,KAAK,EACT,EAAE,iBAAiB,CAAC,cAAc,CAAC,GAAG,KAAK,CAAC,SAAS,CAsRrD;AACD,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"ScrollBox.d.ts","sourceRoot":"","sources":["../../../../src/ink/components/ScrollBox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,KAAK,iBAAiB,EAAE,KAAK,GAAG,EAAsD,MAAM,OAAO,CAAC;AACpH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAGxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAM5C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAE3C,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9B,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B;;;;;;OAMG;IACH,eAAe,EAAE,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3D,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,MAAM,CAAC;IAC3B,eAAe,EAAE,MAAM,MAAM,CAAC;IAC9B,eAAe,EAAE,MAAM,MAAM,CAAC;IAC9B;;;;;OAKG;IACH,oBAAoB,EAAE,MAAM,MAAM,CAAC;IACnC,iBAAiB,EAAE,MAAM,MAAM,CAAC;IAChC;;;OAGG;IACH,cAAc,EAAE,MAAM,MAAM,CAAC;IAC7B;;;;;;OAMG;IACH,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB;;;;;OAKG;IACH,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;IAChD;;;;;;;OAOG;IACH,cAAc,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;CAC5E,CAAC;AACF,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,GAAG,UAAU,GAAG,WAAW,GAAG,WAAW,CAAC,GAAG;IACjG,GAAG,CAAC,EAAE,GAAG,CAAC,eAAe,CAAC,CAAC;IAC3B;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF;;;;;;;;;GASG;AACH,iBAAS,SAAS,CAAC,EACjB,QAAQ,EACR,GAAG,EACH,YAAY,EACZ,GAAG,KAAK,EACT,EAAE,iBAAiB,CAAC,cAAc,CAAC,GAAG,KAAK,CAAC,SAAS,CAkRrD;AACD,eAAe,SAAS,CAAC"}
@@ -89,6 +89,9 @@ function ScrollBox({ children, ref, stickyScroll, ...style }) {
89
89
  markDirty(el);
90
90
  markCommitStart();
91
91
  notifyCoalesced();
92
+ queueRender(el);
93
+ }
94
+ function queueRender(el) {
92
95
  if (renderQueuedRef.current)
93
96
  return;
94
97
  renderQueuedRef.current = true;
@@ -181,32 +184,13 @@ function ScrollBox({ children, ref, stickyScroll, ...style }) {
181
184
  el.scrollAnchor = undefined;
182
185
  const viewportH = el.scrollViewportHeight ?? 0;
183
186
  const maxScroll = Math.max(0, (el.scrollHeight ?? 0) - viewportH);
184
- const distance = maxScroll - (el.scrollTop ?? 0);
185
- if (distance > viewportH && viewportH > 0) {
186
- // FAR jump: drain instead of teleport. The viewport would land on
187
- // rows that were never mounted — their spacer heights are
188
- // DEFAULT_ROW_HEIGHT estimates, so the topPad swallows the view
189
- // (blank transcript) AND it is a fixed point: unmounted rows never
190
- // measure, nothing retried until the next wheel event. The drain
191
- // walks the exact wheel path instead: proportional steps, the
192
- // virtualization window follows per commit (it mounts the union of
193
- // committed + pending), the visual clamp pins to the mounted edge
194
- // while rows measure as they enter — no blank at any distance. The
195
- // renderer's at-bottom re-pin restores sticky when it lands
196
- // (stickyScroll=false + pending undefined + scrollTop >= maxScroll).
197
- el.stickyScroll = false;
198
- el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + distance;
199
- scrollMutated(el);
200
- return;
201
- }
202
187
  el.pendingScrollDelta = undefined;
203
188
  el.stickyScroll = true;
204
- // DOM-direct pre-write of the target (near jumps only): the notify
205
- // below triggers MessageList's window recompute, and its React commit
206
- // must read the NEW scrollTop — the old order (notify first, renderer
207
- // moves scrollTop later) left the window one commit behind. The
208
- // renderer's sticky branch re-pins scrollTop to the exact fresh
209
- // maxScroll next frame, so a stale cached height costs one frame.
189
+ // Jump intent is not wheel debt: mounting the union of the current
190
+ // position and a far pending delta defeats virtualization. MessageList's
191
+ // sticky tail walk mounts the destination directly, even with estimated
192
+ // heights. Publish sticky + target before notifying; the renderer then
193
+ // pins to the fresh maxScroll as those tail rows are measured.
210
194
  el.scrollTop = maxScroll;
211
195
  scrollMutated(el);
212
196
  forceRender(n => n + 1);
@@ -247,8 +231,17 @@ function ScrollBox({ children, ref, stickyScroll, ...style }) {
247
231
  const el = domRef.current;
248
232
  if (!el)
249
233
  return;
234
+ if (el.scrollClampMin === min && el.scrollClampMax === max)
235
+ return;
250
236
  el.scrollClampMin = min;
251
237
  el.scrollClampMax = max;
238
+ // Layout effects publish the new mount window AFTER resetAfterCommit
239
+ // may have painted with the previous bounds. Warm rows need no measure
240
+ // tick, so that paint can otherwise remain on blank spacer indefinitely.
241
+ // Invalidate the blit and schedule a paint, without notifying React
242
+ // subscribers (the window is already committed).
243
+ markDirty(el);
244
+ queueRender(el);
252
245
  }
253
246
  };
254
247
  handleRef.current = handle;
@@ -267,6 +260,18 @@ function ScrollBox({ children, ref, stickyScroll, ...style }) {
267
260
  if (e.deltaY !== 0)
268
261
  handleRef.current?.scrollBy(e.deltaY);
269
262
  }, []);
263
+ // Keep the host ref attached across updates. A new callback would detach
264
+ // it before children's layout effects publish their clamp bounds, making
265
+ // setClampBounds silently miss the commit (child effects run first).
266
+ const attachNode = useCallback((el) => {
267
+ domRef.current = el;
268
+ if (el) {
269
+ el.scrollTop ??= 0;
270
+ el.onStickyRestore = notify;
271
+ }
272
+ // notify only closes over refs, like the imperative handle above.
273
+ // eslint-disable-next-line react-hooks/exhaustive-deps
274
+ }, []);
270
275
  // Structure: outer viewport (overflow:scroll, constrained height) >
271
276
  // inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
272
277
  // but grows beyond it for tall content). flexGrow:1 lets children use
@@ -278,15 +283,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }) {
278
283
  // stickyScroll is passed as a DOM attribute (via ink-box directly) so it's
279
284
  // available on the first render — ref callbacks fire after the initial
280
285
  // commit, which is too late for the first frame.
281
- return _jsx("ink-box", { ref: el => {
282
- domRef.current = el;
283
- if (el) {
284
- el.scrollTop ??= 0;
285
- // Renderer-side sticky restores (positional re-pin at the bottom)
286
- // must reach React subscribers too — see dom.ts onStickyRestore.
287
- el.onStickyRestore = notify;
288
- }
289
- }, onWheel: handleWheel, style: {
286
+ return _jsx("ink-box", { ref: attachNode, onWheel: handleWheel, style: {
290
287
  flexWrap: 'nowrap',
291
288
  flexDirection: style.flexDirection ?? 'row',
292
289
  flexGrow: style.flexGrow ?? 0,
@@ -1 +1 @@
1
- {"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../../src/ink/dom.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../../src/ink/dom.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { createLayoutNode } from './layout/engine.js';
2
2
  import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js';
3
3
  import measureText from './measure-text.js';
4
- import { addPendingClear, nodeCache } from './node-cache.js';
4
+ import { addPendingClear, nodeCache, textPaintCache } from './node-cache.js';
5
5
  import squashTextNodes from './squash-text-nodes.js';
6
6
  import { expandTabs } from './tabstops.js';
7
7
  import wrapText from './wrap-text.js';
@@ -219,12 +219,31 @@ export const createTextNode = (text) => {
219
219
  setTextNodeValue(node, text);
220
220
  return node;
221
221
  };
222
- const measureWrapCache = new WeakMap();
222
+ const textMeasureCache = new WeakMap();
223
+ // Yoga probes several widths/modes repeatedly in one layout. Retain only
224
+ // the current text's results so streaming cannot accumulate old snapshots.
225
+ const TEXT_MEASURE_CACHE_SIZE = 8;
223
226
  const measureTextNode = function (node, width, widthMode) {
224
227
  const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node);
225
- // Expand tabs for measurement (worst case: 8 spaces each).
226
- // Actual tab expansion happens in output.ts based on screen position.
227
- const text = expandTabs(rawText);
228
+ const textWrap = node.style.textWrap ?? 'wrap';
229
+ let cache = textMeasureCache.get(node);
230
+ if (cache === undefined || cache.rawText !== rawText || cache.wrap !== textWrap) {
231
+ // Tabs use the same measurement expansion as the uncached path.
232
+ cache = { rawText, text: expandTabs(rawText), wrap: textWrap, entries: [] };
233
+ textMeasureCache.set(node, cache);
234
+ }
235
+ for (const entry of cache.entries) {
236
+ if (Object.is(entry.width, width) && entry.widthMode === widthMode)
237
+ return entry.result;
238
+ }
239
+ // Check above before measureText walks every line, even on a wrap-cache hit.
240
+ const result = measureTextDimensions(cache.text, width, widthMode, textWrap);
241
+ if (cache.entries.length === TEXT_MEASURE_CACHE_SIZE)
242
+ cache.entries.shift();
243
+ cache.entries.push({ width, widthMode, result });
244
+ return result;
245
+ };
246
+ function measureTextDimensions(text, width, widthMode, textWrap) {
228
247
  const dimensions = measureText(text, width);
229
248
  // Text fits into container, no need to wrap
230
249
  if (dimensions.width <= width) {
@@ -247,66 +266,11 @@ const measureTextNode = function (node, width, widthMode) {
247
266
  const effectiveWidth = Math.max(width, dimensions.width);
248
267
  return measureText(text, effectiveWidth);
249
268
  }
250
- const textWrap = node.style.textWrap ?? 'wrap';
251
- // Incremental path: same node, same width/wrap, text grew by append.
252
- const cached = measureWrapCache.get(node);
253
- if (cached !== undefined && cached.width === width && cached.wrap === textWrap) {
254
- if (cached.text === text) {
255
- // Same-frame repeat measure (yoga min/max probing) — free.
256
- return cached.result;
257
- }
258
- if (text.startsWith(cached.text)) {
259
- const tail = text.slice(cached.tailStart);
260
- const tailWrapped = wrapText(tail, width, textWrap);
261
- const tailDim = measureText(tailWrapped, width);
262
- // Commit fully-terminated logical lines into the head so the tail
263
- // stays bounded at one line of source text.
264
- const lastNewline = tail.lastIndexOf('\n');
265
- let { headHeight, tailStart } = cached;
266
- let tailHeight = tailDim.height;
267
- if (lastNewline > 0) {
268
- const committed = wrapText(tail.slice(0, lastNewline + 1), width, textWrap);
269
- headHeight += measureText(committed, width).height;
270
- tailStart += lastNewline + 1;
271
- tailHeight = measureText(wrapText(tail.slice(lastNewline + 1), width, textWrap), width).height;
272
- }
273
- const result = {
274
- width: Math.max(cached.result.width, tailDim.width),
275
- height: headHeight + tailHeight,
276
- };
277
- measureWrapCache.set(node, {
278
- text,
279
- width,
280
- wrap: textWrap,
281
- headHeight,
282
- tailStart,
283
- tailHeight,
284
- result,
285
- });
286
- return result;
287
- }
288
- // Text replaced or shrunk (node reuse for a different message): fall
289
- // through to the full path and overwrite the cache below.
290
- }
291
269
  const wrappedText = wrapText(text, width, textWrap);
292
- const fullResult = measureText(wrappedText, width);
293
- // Seed the incremental cache: commit everything before the last newline.
294
- const lastNewline = text.lastIndexOf('\n');
295
- const tailStart = lastNewline === -1 ? 0 : lastNewline + 1;
296
- const headHeight = tailStart === 0
297
- ? 0
298
- : measureText(wrapText(text.slice(0, tailStart), width, textWrap), width).height;
299
- measureWrapCache.set(node, {
300
- text,
301
- width,
302
- wrap: textWrap,
303
- headHeight,
304
- tailStart,
305
- tailHeight: fullResult.height - headHeight,
306
- result: fullResult,
307
- });
308
- return fullResult;
309
- };
270
+ // The wrapper has already chosen physical rows. Reapplying width-based
271
+ // row counting here double-counts trailing spaces at fractional widths.
272
+ return measureText(wrappedText, Infinity);
273
+ }
310
274
  // ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.
311
275
  // No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)
312
276
  // already wrapped to the target width and each line is exactly one terminal row.
@@ -328,6 +292,9 @@ export const markDirty = (node) => {
328
292
  if (current.nodeName !== '#text') {
329
293
  ;
330
294
  (current).dirty = true;
295
+ // Culling can clear the paint dirty bit without preparing changed text.
296
+ // Invalidate at mutation time so re-entry cannot reuse stale content.
297
+ textPaintCache.delete(current);
331
298
  // Only mark yoga dirty on leaf nodes that have measure functions
332
299
  if (!markedYoga &&
333
300
  (current.nodeName === 'ink-text' ||
@@ -362,6 +329,7 @@ export const markTreeDirty = (node) => {
362
329
  continue;
363
330
  const element = current;
364
331
  element.dirty = true;
332
+ textPaintCache.delete(element);
365
333
  // markDirty() is only legal on yoga nodes that carry a measure function;
366
334
  // those are exactly the two text node kinds (see createNode).
367
335
  if ((current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') &&
@@ -1,19 +1,11 @@
1
- import { type LayoutNode } from './layout/node.js';
1
+ import type { LayoutNode } from './layout/node.js';
2
2
  /**
3
- * Returns the yoga node's content width (computed width minus padding and
4
- * border).
5
- *
6
- * Warning: can return a value WIDER than the parent container. In a
7
- * column-direction flex parent, width is the cross axis — align-items:
8
- * stretch never shrinks children below their intrinsic size, so the text
9
- * node overflows (standard CSS behavior). Yoga measures leaf nodes in two
10
- * passes: the AtMost pass determines width, the Exactly pass determines
11
- * height. getComputedWidth() reflects the wider AtMost result while
12
- * getComputedHeight() reflects the narrower Exactly result. Callers that
13
- * use this for wrapping should clamp to actual available screen space so
14
- * the rendered line count stays consistent with the layout height.
15
- * @param yogaNode - the laid-out node to measure.
16
- * @returns the node's content width: computed width minus padding and border.
3
+ * Return the text constraint that produced the laid-out height. Pixel-grid
4
+ * rounding can widen the painted box; wrapping at that rounded width would
5
+ * produce fewer rows than Yoga reserved and leave blank space at the bottom.
6
+ * The layout engine retains this constraint through its cache-hit paths.
7
+ * @param yogaNode - a laid-out text node with a measure function.
8
+ * @returns content width, already excluding padding and border.
17
9
  */
18
10
  declare const getMaxWidth: (yogaNode: LayoutNode) => number;
19
11
  export default getMaxWidth;
@@ -1 +1 @@
1
- {"version":3,"file":"get-max-width.d.ts","sourceRoot":"","sources":["../../../src/ink/get-max-width.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAE9D;;;;;;;;;;;;;;;GAeG;AACH,QAAA,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,MAQ3C,CAAA;AAED,eAAe,WAAW,CAAA"}
1
+ {"version":3,"file":"get-max-width.d.ts","sourceRoot":"","sources":["../../../src/ink/get-max-width.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;;;;;;GAOG;AACH,QAAA,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,MAE3C,CAAA;AAED,eAAe,WAAW,CAAA"}
@@ -1,25 +1,12 @@
1
- import { LayoutEdge } from './layout/node.js';
2
1
  /**
3
- * Returns the yoga node's content width (computed width minus padding and
4
- * border).
5
- *
6
- * Warning: can return a value WIDER than the parent container. In a
7
- * column-direction flex parent, width is the cross axis — align-items:
8
- * stretch never shrinks children below their intrinsic size, so the text
9
- * node overflows (standard CSS behavior). Yoga measures leaf nodes in two
10
- * passes: the AtMost pass determines width, the Exactly pass determines
11
- * height. getComputedWidth() reflects the wider AtMost result while
12
- * getComputedHeight() reflects the narrower Exactly result. Callers that
13
- * use this for wrapping should clamp to actual available screen space so
14
- * the rendered line count stays consistent with the layout height.
15
- * @param yogaNode - the laid-out node to measure.
16
- * @returns the node's content width: computed width minus padding and border.
2
+ * Return the text constraint that produced the laid-out height. Pixel-grid
3
+ * rounding can widen the painted box; wrapping at that rounded width would
4
+ * produce fewer rows than Yoga reserved and leave blank space at the bottom.
5
+ * The layout engine retains this constraint through its cache-hit paths.
6
+ * @param yogaNode - a laid-out text node with a measure function.
7
+ * @returns content width, already excluding padding and border.
17
8
  */
18
9
  const getMaxWidth = (yogaNode) => {
19
- return (yogaNode.getComputedWidth() -
20
- yogaNode.getComputedPadding(LayoutEdge.Left) -
21
- yogaNode.getComputedPadding(LayoutEdge.Right) -
22
- yogaNode.getComputedBorder(LayoutEdge.Left) -
23
- yogaNode.getComputedBorder(LayoutEdge.Right));
10
+ return yogaNode.getComputedMeasureWidth();
24
11
  };
25
12
  export default getMaxWidth;
@@ -67,6 +67,7 @@ export default class Ink {
67
67
  private altScreenMouseTracking;
68
68
  private mainScreenFrameState;
69
69
  private prevFrameContaminated;
70
+ private needsSurfaceRepaint;
70
71
  private needsEraseBeforePaint;
71
72
  private cursorDeclaration;
72
73
  private displayCursor;