dsh-code 1.0.2 → 1.0.4

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 (53) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +285 -271
  3. package/bin/deepseek.mjs +26 -3
  4. package/lib/index.mjs +2962 -1560
  5. package/lib/types/app.d.ts +13 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/editor-keys.d.ts +105 -0
  8. package/lib/types/git-workflow.d.ts +6 -2
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/model-capabilities.d.ts +82 -0
  14. package/lib/types/provider-settings.d.ts +84 -0
  15. package/lib/types/render/lines.d.ts +25 -0
  16. package/lib/types/render/markdown.d.ts +1 -1
  17. package/lib/types/render/projection.d.ts +22 -2
  18. package/lib/types/render/status.d.ts +22 -15
  19. package/lib/types/render/text.d.ts +15 -9
  20. package/lib/types/render/width.d.ts +29 -0
  21. package/lib/types/session-directory.d.ts +27 -0
  22. package/lib/types/settings-file.d.ts +33 -0
  23. package/lib/types/skills.d.ts +1 -1
  24. package/lib/types/store.d.ts +10 -0
  25. package/lib/types/subagents.d.ts +13 -3
  26. package/package.json +159 -159
  27. package/src/app.ts +4514 -3892
  28. package/src/approval.ts +8 -3
  29. package/src/authorization-panel.ts +2 -4
  30. package/src/commands.ts +27 -3
  31. package/src/editor-keys.ts +371 -0
  32. package/src/git-workflow.ts +10 -6
  33. package/src/index.ts +1752 -1523
  34. package/src/input-split.ts +191 -0
  35. package/src/internals.ts +26 -8
  36. package/src/kernel-panels.ts +26 -10
  37. package/src/keyboard.ts +123 -88
  38. package/src/mentions.ts +42 -9
  39. package/src/model-capabilities.ts +318 -0
  40. package/src/provider-settings.ts +220 -0
  41. package/src/questions.ts +20 -0
  42. package/src/render/lines.ts +415 -356
  43. package/src/render/markdown.ts +18 -19
  44. package/src/render/projection.ts +162 -52
  45. package/src/render/status.ts +76 -71
  46. package/src/render/text.ts +158 -150
  47. package/src/render/width.ts +189 -0
  48. package/src/session-directory.ts +56 -0
  49. package/src/settings-file.ts +56 -0
  50. package/src/skills.ts +19 -6
  51. package/src/store.ts +26 -7
  52. package/src/subagents.ts +39 -6
  53. package/src/theme-panel.ts +79 -72
@@ -126,37 +126,31 @@ export const STATUS_ITEM_SEPARATOR = ' · '
126
126
  export const STATUS_CYCLE_HINT = ' (shift+tab to cycle)'
127
127
 
128
128
  /**
129
- * Interior columns of the segmented context bar (content-type segments plus
130
- * the free tail whose right edge carries the usage readout). The layout
131
- * starts every bar at this width so the drop ladder can pre-measure the
132
- * group, then shrinks the bar inside a tighter budget before dropping it
133
- * (see CONTEXT_MIN_WIDTH) rather than asking the layout for more room.
129
+ * Interior columns of the context bar. The layout starts every bar at this
130
+ * width so the drop ladder can pre-measure the group, then degrades the
131
+ * readout and shrinks the bar inside a tighter budget before dropping the
132
+ * group (see CONTEXT_MIN_WIDTH) rather than asking the layout for more room.
134
133
  */
135
134
  export const CONTEXT_BAR_WIDTH = 24
136
135
  /** Occupancy at which the usage readout flips from brand blue to amber. */
137
136
  const CONTEXT_WARN_PERCENT = 90
138
- /** Free-tail floor in columns: wide enough for the bare percent readout, so
139
- * the warning stays visible even at 100%+ occupancy. */
140
- const CONTEXT_MIN_FREE = 5
141
137
  /**
142
- * Narrowest bar width the drop ladder tries before giving up on the context
143
- * group: the bar shrinks inside its own budget first (a few columns still
144
- * show the bare percent readout) and only then drops as a whole.
138
+ * Narrowest bar width the drop ladder keeps before dropping the whole
139
+ * context group: the bar shrinks to this floor first (the absolute readout
140
+ * survives), and only past it does the readout degrade and the group go.
145
141
  */
146
142
  const CONTEXT_MIN_WIDTH = 5
147
143
 
148
144
  /**
149
- * Render context occupancy as ONE stepless bar: a solid DeepSeek-blue fill
150
- * run, a dim dotted free track, and the usage readout riding the track's
151
- * right edge (`12.3K/1.0M 25%`, shrinking to the bare percent as the track
152
- * narrows). No per-content-type segmentation. Column split is deterministic:
153
- * the free share is `Math.round(free/window*width)` clamped to at least
154
- * CONTEXT_MIN_FREE columns and at most the full width; the fill takes every
155
- * remaining column, so a given occupancy always renders the identical bar.
156
- * The readout flips to amber once occupancy reaches the warning threshold.
157
- * @param usedTokens - reported used tokens (drives the readout and percent).
145
+ * Render context occupancy as ONE stepless proportional bar: a solid
146
+ * DeepSeek-blue fill run tracking the occupancy and a dim dotted free
147
+ * track for the rest. Nothing else lives inside the bar the usage
148
+ * readout rides outside it (see contextGroupSpans) so the geometry
149
+ * always reads as the true remaining share. A given occupancy always
150
+ * renders the identical bar.
151
+ * @param usedTokens - reported used tokens.
158
152
  * @param contextWindow - route capacity.
159
- * @param width - total bar interior columns.
153
+ * @param width - total bar columns.
160
154
  * @returns tone-split spans for the footer to paint.
161
155
  */
162
156
  export function contextBar(
@@ -166,27 +160,42 @@ export function contextBar(
166
160
  ): readonly StatusSpan[] {
167
161
  if (width <= 0 || contextWindow <= 0) return []
168
162
  const used = Math.max(0, usedTokens)
169
- const percent = Math.round(used / contextWindow * 100)
170
- const warning = percent >= CONTEXT_WARN_PERCENT
171
- const readoutTone: StatusTone = warning ? 'warn' : 'value'
172
-
173
- const freeShare = Math.round(Math.max(0, contextWindow - used) / contextWindow * width)
174
- const freeColumns = Math.min(width, Math.max(freeShare, CONTEXT_MIN_FREE))
175
- const usedColumns = Math.max(0, width - freeColumns)
163
+ const fill = Math.min(width, Math.max(0, Math.round(used / contextWindow * width)))
164
+ const spans: StatusSpan[] = []
165
+ if (fill > 0) spans.push({ text: ''.repeat(fill), tone: 'ctxFill' })
166
+ const free = width - fill
167
+ if (free > 0) spans.push({ text: '░'.repeat(free), tone: 'label' })
168
+ return spans
169
+ }
176
170
 
177
- const total = `${formatTokens(used)}/${formatTokens(contextWindow)}`
178
- const percentText = `${percent}%`
179
- const readout = freeColumns >= visibleColumns(`${total} ${percentText}`)
180
- ? `${total} ${percentText}`
181
- : freeColumns >= visibleColumns(percentText)
182
- ? percentText
183
- : ''
171
+ /** How much usage detail the context group's readout carries. */
172
+ export type ContextReadoutMode = 'full' | 'percent' | 'none'
184
173
 
185
- const spans: StatusSpan[] = []
186
- if (usedColumns > 0) spans.push({ text: '█'.repeat(usedColumns), tone: 'ctxFill' })
187
- const pad = freeColumns - visibleColumns(readout)
188
- if (pad > 0) spans.push({ text: '░'.repeat(pad), tone: 'label' })
189
- if (readout !== '') spans.push({ text: readout, tone: readoutTone })
174
+ /**
175
+ * Compose the context group: the proportional bar plus the usage readout
176
+ * OUTSIDE the bar, so the dotted track keeps its proportional meaning no
177
+ * matter how wide the readout is. `full` reads `12.3K/1.0M 25%`; `percent`
178
+ * drops the absolute pair; `none` is the bare bar. The readout turns amber
179
+ * once occupancy reaches the warning threshold.
180
+ */
181
+ export function contextGroupSpans(
182
+ usedTokens: number,
183
+ contextWindow: number,
184
+ barWidth: number,
185
+ readout: ContextReadoutMode,
186
+ ): readonly StatusSpan[] {
187
+ const spans: StatusSpan[] = [{ text: 'context ', tone: 'label' }]
188
+ spans.push(...contextBar(usedTokens, contextWindow, barWidth))
189
+ if (readout === 'none' || barWidth <= 0 || contextWindow <= 0) return spans
190
+ const used = Math.max(0, usedTokens)
191
+ const percent = Math.round(used / contextWindow * 100)
192
+ const text = readout === 'full'
193
+ ? `${formatTokens(used)}/${formatTokens(contextWindow)} ${percent}%`
194
+ : `${percent}%`
195
+ spans.push(
196
+ { text: ' ', tone: 'label' },
197
+ { text, tone: percent >= CONTEXT_WARN_PERCENT ? 'warn' : 'value' },
198
+ )
190
199
  return spans
191
200
  }
192
201
 
@@ -459,17 +468,13 @@ function buildCandidates(
459
468
  id: 'cache',
460
469
  })
461
470
  }
462
- // Context occupancy as a segmented bar: per-content-type runs colored by
463
- // their own blue shade with a right-aligned usage readout. The used total
464
- // is the most recent reported prompt size against the advertised route
465
- // capacity (the same figures the old bracket bar showed).
471
+ // Context occupancy as a purely proportional bar with the usage readout
472
+ // riding outside it: the used total is the most recent reported prompt
473
+ // size against the advertised route capacity.
466
474
  if (stats.contextWindow > 0 && stats.lastPromptTokens > 0 && enabled.has('context')) {
467
475
  left.push({
468
476
  group: {
469
- spans: [
470
- { text: 'context ', tone: 'label' },
471
- ...contextBar(stats.lastPromptTokens, stats.contextWindow, contextWidth),
472
- ],
477
+ spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, 'full'),
473
478
  },
474
479
  rank: RANK_CONTEXT,
475
480
  id: 'context',
@@ -569,19 +574,18 @@ export function layoutStatusBar(
569
574
  const leftKept = [...orderedLeft]
570
575
  const rightKept = [...orderedRight]
571
576
 
572
- // Context shrink state: the bar starts at its full budget and is rebuilt at
573
- // ever narrower widths before the drop ladder is allowed to discard it.
577
+ // Context degradation state: the readout drops its absolute pair first,
578
+ // then the bar shrinks inside its own budget, and only then is the whole
579
+ // group removed — the proportional meter outlives the auxiliary numbers.
574
580
  // Rebuilding replaces the group's spans in place so width() re-measures it.
581
+ let contextReadout: ContextReadoutMode = 'full'
575
582
  let contextWidth = maxContextWidth
576
583
  const rebuildContext = (): void => {
577
584
  const index = leftKept.findIndex(entry => entry.id === 'context')
578
585
  if (index < 0) return
579
586
  leftKept[index] = {
580
587
  group: {
581
- spans: [
582
- { text: 'context ', tone: 'label' },
583
- ...contextBar(stats.lastPromptTokens, stats.contextWindow, contextWidth),
584
- ],
588
+ spans: contextGroupSpans(stats.lastPromptTokens, stats.contextWindow, contextWidth, contextReadout),
585
589
  },
586
590
  rank: RANK_CONTEXT,
587
591
  id: 'context',
@@ -599,23 +603,24 @@ export function layoutStatusBar(
599
603
  }
600
604
 
601
605
  while (width() > budget) {
602
- // Context is the lowest-priority visual group. Shrink or remove it before
603
- // sacrificing the permission badge or its Shift+Tab affordance.
604
- // Shrink the context bar instead of dropping it: reserve everything else
605
- // and hand the deficit to the bar, clamped to CONTEXT_MIN_WIDTH. The
606
- // fixed label ('context ') and the bar's own readout keep shrinking to
607
- // the bare percent, so a tight terminal keeps context visible longer.
608
- if (leftKept.some(entry => entry.id === 'context') && contextWidth > CONTEXT_MIN_WIDTH) {
609
- const overflow = width() - budget
610
- contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow)
611
- rebuildContext()
612
- continue
613
- }
614
- const contextIndex = leftKept.findIndex(entry => entry.id === 'context')
615
- if (contextIndex >= 0) {
616
- // Once the meter reaches its minimum useful width, remove the whole
617
- // group before touching the permission badge or its keyboard hint.
618
- leftKept.splice(contextIndex, 1)
606
+ // Context is the lowest-priority visual group: the bar shrinks inside
607
+ // its own budget first (the absolute readout survives), then the
608
+ // readout degrades to the bare percent, and only then does the whole
609
+ // group go before the permission badge or its Shift+Tab affordance
610
+ // is touched.
611
+ if (leftKept.some(entry => entry.id === 'context')) {
612
+ if (contextWidth > CONTEXT_MIN_WIDTH) {
613
+ const overflow = width() - budget
614
+ contextWidth = Math.max(CONTEXT_MIN_WIDTH, contextWidth - overflow)
615
+ rebuildContext()
616
+ continue
617
+ }
618
+ if (contextReadout === 'full') {
619
+ contextReadout = 'percent'
620
+ rebuildContext()
621
+ continue
622
+ }
623
+ leftKept.splice(leftKept.findIndex(entry => entry.id === 'context'), 1)
619
624
  continue
620
625
  }
621
626
  if (hint && rightKept.length > 0 && leftKept.length > 0) {
@@ -1,150 +1,158 @@
1
- /**
2
- * Display-boundary sanitization for externally sourced text (model output,
3
- * tool payloads, skill descriptions). Control characters — including ANSI
4
- * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
- * terminal, letting output rewrite the screen or inject prompts. Newlines
6
- * survive; everything else in C0/C1 plus DEL becomes a visible `\xNN`
7
- * escape, and bidi overrides / invisible format controls / Unicode line and
8
- * paragraph separators become a visible `\uXXXX` escape (terminal emulators
9
- * that render bidirectional text would otherwise reorder the displayed
10
- * glyphs and let a command read as something it is not).
11
- *
12
- * @module @deepseek-ai/dsh-code/render/text
13
- */
14
-
15
- /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
16
- const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
17
-
18
- /**
19
- * Bidi overrides and isolates (U+202A-202E, U+2066-2069), the Arabic Letter
20
- * Mark (U+061C), directional and zero-width format characters (U+200B,
21
- * U+200E/200F, U+2060-2064, U+FEFF), and Unicode line/paragraph separators
22
- * (U+2028/2029). Terminal emulators with bidi support (Windows Terminal,
23
- * iTerm2, kitty, WezTerm) reorder or hide these, so they must never reach
24
- * the terminal raw.
25
- */
26
- const INVISIBLE_ESCAPE = /[\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu
27
-
28
- /**
29
- * Escape control and deceptive characters so externally sourced text cannot
30
- * drive the terminal. C0/C1/DEL render as a literal `\xNN` escape; bidi,
31
- * invisible-format, and separator controls render as a literal `\uXXXX`
32
- * escape. Newlines and tabs survive (budgeted callers normalize tabs).
33
- * @param text - raw text from a session event, tool payload, or catalog.
34
- * @returns display-safe text with every injectable character made visible.
35
- */
36
- export function displayText(text: string): string {
37
- return text
38
- .replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
39
- .replace(INVISIBLE_ESCAPE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
40
- }
41
-
42
- /** Collapse external text to one terminal-safe logical row. */
43
- export function singleLineText(text: string): string {
44
- return displayText(text).replace(/\r?\n/gu, ' ').replace(/\t/gu, ' ')
45
- }
46
-
47
- /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
48
- function cellWidth(text: string): number {
49
- let columns = 0
50
- for (const char of text) {
51
- columns += (char.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1
52
- }
53
- return columns
54
- }
55
-
56
- /**
57
- * Truncate one display-safe row without ever exceeding its physical-column
58
- * budget. The ellipsis is included inside the budget, matching Codex's popup
59
- * truncation contract; the previous app-local helper appended it after the
60
- * row was already full and could force an extra terminal wrap.
61
- */
62
- export function truncateColumns(text: string, columns: number): string {
63
- const limit = Math.max(0, Math.floor(columns))
64
- if (limit === 0) return ''
65
- if (cellWidth(text) <= limit) return text
66
-
67
- const contentLimit = limit - 1
68
- let used = 0
69
- let result = ''
70
- for (const char of text) {
71
- const width = cellWidth(char)
72
- if (used + width > contentLimit) break
73
- result += char
74
- used += width
75
- }
76
- return `${result}…`
77
- }
78
-
79
- /** A display-safe suffix bounded by terminal rows and columns. */
80
- export interface DisplayTail {
81
- /** Sanitized suffix suitable for direct terminal rendering. */
82
- text: string
83
- /** Whether content before the returned suffix was omitted. */
84
- truncated: boolean
85
- }
86
-
87
- /** Read one Unicode character immediately before `end`. */
88
- function previousCharacter(text: string, end: number): { char: string; start: number } {
89
- const last = text.charCodeAt(end - 1)
90
- if (last >= 0xdc00 && last <= 0xdfff && end >= 2) {
91
- const first = text.charCodeAt(end - 2)
92
- if (first >= 0xd800 && first <= 0xdbff) {
93
- return { char: text.slice(end - 2, end), start: end - 2 }
94
- }
95
- }
96
- return { char: text.slice(end - 1, end), start: end - 1 }
97
- }
98
-
99
- /**
100
- * Keep only the newest display-safe text that fits a terminal rectangle.
101
- * The scan walks backward and stops as soon as the suffix is full, so a long
102
- * reasoning stream does not rescan its entire accumulated prefix per chunk.
103
- * Explicit newlines and terminal wrapping both consume rows; tabs expand to
104
- * two spaces so terminal tab stops (which render at contextual column 8
105
- * boundaries, not at the budgeted cell count) cannot inflate the physical
106
- * row count of the live region.
107
- * @param text - raw externally sourced text.
108
- * @param columns - available terminal columns.
109
- * @param rows - available terminal rows.
110
- * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
111
- */
112
- export function displayTail(text: string, columns: number, rows: number): DisplayTail {
113
- const columnLimit = Math.max(1, Math.floor(columns))
114
- const rowLimit = Math.max(1, Math.floor(rows))
115
- const reversed: string[] = []
116
- let row = 1
117
- let used = 0
118
- let end = text.length
119
-
120
- while (end > 0) {
121
- const previous = previousCharacter(text, end)
122
- if (previous.char === '\n') {
123
- if (row >= rowLimit) break
124
- reversed.push('\n')
125
- row += 1
126
- used = 0
127
- end = previous.start
128
- continue
129
- }
130
-
131
- const safe = previous.char === '\t' ? ' ' : displayText(previous.char)
132
- const width = cellWidth(safe)
133
- if (used > 0 && used + width > columnLimit) {
134
- if (row >= rowLimit) break
135
- // Materialize the soft wrap. Ink otherwise reflows at word boundaries
136
- // and can turn a cell-counted two-row suffix into three rendered rows.
137
- reversed.push('\n')
138
- row += 1
139
- used = 0
140
- }
141
- const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit)
142
- if (row + extraRows > rowLimit) break
143
- row += extraRows
144
- reversed.push(safe)
145
- used = extraRows === 0 ? used + width : width - extraRows * columnLimit
146
- end = previous.start
147
- }
148
-
149
- return { text: reversed.reverse().join(''), truncated: end > 0 }
150
- }
1
+ /**
2
+ * Display-boundary sanitization for externally sourced text (model output,
3
+ * tool payloads, skill descriptions). Control characters — including ANSI
4
+ * CSI/OSC escape sequences — would otherwise pass through Ink into the
5
+ * terminal, letting output rewrite the screen or inject prompts. Newlines
6
+ * survive; everything else in C0/C1 plus DEL becomes a visible `\xNN`
7
+ * escape, and bidi overrides / invisible format controls / Unicode line and
8
+ * paragraph separators become a visible `\uXXXX` escape (terminal emulators
9
+ * that render bidirectional text would otherwise reorder the displayed
10
+ * glyphs and let a command read as something it is not).
11
+ *
12
+ * @module @deepseek-ai/dsh-code/render/text
13
+ */
14
+
15
+ import { graphemeWidth, splitGraphemes, stringWidth } from './width.ts'
16
+
17
+ /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
18
+ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
19
+
20
+ /**
21
+ * Bidi overrides and isolates (U+202A-202E, U+2066-2069), the Arabic Letter
22
+ * Mark (U+061C), directional and zero-width format characters (U+200B,
23
+ * U+200E/200F, U+2060-2064, U+FEFF), and Unicode line/paragraph separators
24
+ * (U+2028/2029). Terminal emulators with bidi support (Windows Terminal,
25
+ * iTerm2, kitty, WezTerm) reorder or hide these, so they must never reach
26
+ * the terminal raw.
27
+ */
28
+ const INVISIBLE_ESCAPE = /[\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu
29
+
30
+ /**
31
+ * Escape control and deceptive characters so externally sourced text cannot
32
+ * drive the terminal. C0/C1/DEL render as a literal `\xNN` escape; bidi,
33
+ * invisible-format, and separator controls render as a literal `\uXXXX`
34
+ * escape. Newlines and tabs survive (budgeted callers normalize tabs).
35
+ * @param text - raw text from a session event, tool payload, or catalog.
36
+ * @returns display-safe text with every injectable character made visible.
37
+ */
38
+ export function displayText(text: string): string {
39
+ return text
40
+ .replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
41
+ .replace(INVISIBLE_ESCAPE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
42
+ }
43
+
44
+ /** Collapse external text to one terminal-safe logical row. */
45
+ export function singleLineText(text: string): string {
46
+ return displayText(text).replace(/\r?\n/gu, ' ↵ ').replace(/\t/gu, ' ')
47
+ }
48
+
49
+ /**
50
+ * Truncate one display-safe row without ever exceeding its physical-column
51
+ * budget. The ellipsis is included inside the budget, matching Codex's popup
52
+ * truncation contract; the cut walks grapheme clusters so emoji and
53
+ * combining sequences never split mid-cluster.
54
+ */
55
+ export function truncateColumns(text: string, columns: number): string {
56
+ const limit = Math.max(0, Math.floor(columns))
57
+ if (limit === 0) return ''
58
+ if (stringWidth(text) <= limit) return text
59
+
60
+ const contentLimit = limit - 1
61
+ let used = 0
62
+ let result = ''
63
+ for (const cluster of splitGraphemes(text)) {
64
+ const width = graphemeWidth(cluster)
65
+ if (used + width > contentLimit) break
66
+ result += cluster
67
+ used += width
68
+ }
69
+ return `${result}…`
70
+ }
71
+
72
+ /** A display-safe suffix bounded by terminal rows and columns. */
73
+ export interface DisplayTail {
74
+ /** Sanitized suffix suitable for direct terminal rendering. */
75
+ text: string
76
+ /** Whether content before the returned suffix was omitted. */
77
+ truncated: boolean
78
+ }
79
+
80
+ /** Punctuation that must never START a physical row (CJK kinsoku tail set). */
81
+ const ROW_START_FORBIDDEN = ',。、;:!?)】」』〉》…‥'
82
+
83
+ /** Punctuation that must never END a physical row (CJK kinsoku head set). */
84
+ const ROW_END_FORBIDDEN = '(【「『〈《'
85
+
86
+ /**
87
+ * Keep the newest display-safe text that fits a terminal rectangle, wrapping
88
+ * FORWARD from the start of the text and slicing the tail rows.
89
+ *
90
+ * Forward wrapping is what keeps a streaming tail calm: rows already produced
91
+ * never re-wrap as tokens append (a backward scan recomputes every wrap point
92
+ * per chunk and the whole visible block jumps), and the wrap rules match the
93
+ * settled text's renderer so the flush at turn end does not reflow the block
94
+ * a second time. CJK kinsoku applies at both edges: closing punctuation
95
+ * overhangs up to two cells onto the filled row instead of starting the next
96
+ * one (within the caret column the caller reserves), and opening punctuation
97
+ * moves down instead of dangling at a row end. Tabs expand to two spaces so
98
+ * terminal tab stops cannot inflate the physical row count; clusters carry
99
+ * emoji presentation and combining marks whole.
100
+ * @param text - raw externally sourced text.
101
+ * @param columns - available terminal columns.
102
+ * @param rows - available terminal rows.
103
+ * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
104
+ */
105
+ export function displayTail(text: string, columns: number, rows: number): DisplayTail {
106
+ const columnLimit = Math.max(1, Math.floor(columns))
107
+ const rowLimit = Math.max(1, Math.floor(rows))
108
+ const wrapped: string[] = []
109
+ let current = ''
110
+ let used = 0
111
+ let lastCluster = ''
112
+ const flush = (): void => {
113
+ wrapped.push(current)
114
+ current = ''
115
+ used = 0
116
+ lastCluster = ''
117
+ }
118
+
119
+ for (const cluster of splitGraphemes(text)) {
120
+ if (cluster === '\n') {
121
+ flush()
122
+ continue
123
+ }
124
+ const safe = cluster === '\t' ? ' ' : displayText(cluster)
125
+ // safe can be a multi-character escape literal (\xNN / \uXXXX); only
126
+ // stringWidth budgets the whole visible escape, never its first byte.
127
+ const width = stringWidth(safe)
128
+ if (used > 0 && used + width > columnLimit) {
129
+ const overhang = width <= 2 && ROW_START_FORBIDDEN.includes(cluster)
130
+ if (!overhang) {
131
+ // Kinsoku head: an opening mark at the row edge moves down with the
132
+ // incoming cluster instead of dangling at the end of the filled row.
133
+ if (lastCluster !== '' && ROW_END_FORBIDDEN.includes(lastCluster)) {
134
+ const carried = lastCluster
135
+ current = current.slice(0, current.length - carried.length)
136
+ flush()
137
+ current = carried
138
+ used = stringWidth(carried)
139
+ } else {
140
+ flush()
141
+ }
142
+ }
143
+ }
144
+ current += safe
145
+ used += width
146
+ lastCluster = cluster
147
+ }
148
+ // A trailing newline means one deliberate empty caret row, but that row
149
+ // must never evict real content or fake a truncation marker: flush the
150
+ // content first, decide truncation on content alone, then append the blank
151
+ // row only when the whole tail still fits the budget.
152
+ const trailingBlank = current === '' && wrapped.length > 0 && text.endsWith('\n')
153
+ if (current !== '') flush()
154
+ const truncated = wrapped.length > rowLimit
155
+ const kept = truncated ? wrapped.slice(-rowLimit) : wrapped
156
+ const keptRows = trailingBlank && kept.length < rowLimit ? [...kept, ''] : kept
157
+ return { text: keptRows.join('\n'), truncated }
158
+ }