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.
- package/README.en.md +21 -13
- package/README.md +285 -271
- package/bin/deepseek.mjs +26 -3
- package/lib/index.mjs +2962 -1560
- package/lib/types/app.d.ts +13 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/editor-keys.d.ts +105 -0
- package/lib/types/git-workflow.d.ts +6 -2
- package/lib/types/index.d.ts +28 -0
- package/lib/types/input-split.d.ts +54 -0
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/keyboard.d.ts +8 -0
- package/lib/types/model-capabilities.d.ts +82 -0
- package/lib/types/provider-settings.d.ts +84 -0
- package/lib/types/render/lines.d.ts +25 -0
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +22 -2
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/render/text.d.ts +15 -9
- package/lib/types/render/width.d.ts +29 -0
- package/lib/types/session-directory.d.ts +27 -0
- package/lib/types/settings-file.d.ts +33 -0
- package/lib/types/skills.d.ts +1 -1
- package/lib/types/store.d.ts +10 -0
- package/lib/types/subagents.d.ts +13 -3
- package/package.json +159 -159
- package/src/app.ts +4514 -3892
- package/src/approval.ts +8 -3
- package/src/authorization-panel.ts +2 -4
- package/src/commands.ts +27 -3
- package/src/editor-keys.ts +371 -0
- package/src/git-workflow.ts +10 -6
- package/src/index.ts +1752 -1523
- package/src/input-split.ts +191 -0
- package/src/internals.ts +26 -8
- package/src/kernel-panels.ts +26 -10
- package/src/keyboard.ts +123 -88
- package/src/mentions.ts +42 -9
- package/src/model-capabilities.ts +318 -0
- package/src/provider-settings.ts +220 -0
- package/src/questions.ts +20 -0
- package/src/render/lines.ts +415 -356
- package/src/render/markdown.ts +18 -19
- package/src/render/projection.ts +162 -52
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +158 -150
- package/src/render/width.ts +189 -0
- package/src/session-directory.ts +56 -0
- package/src/settings-file.ts +56 -0
- package/src/skills.ts +19 -6
- package/src/store.ts +26 -7
- package/src/subagents.ts +39 -6
- package/src/theme-panel.ts +79 -72
package/src/render/status.ts
CHANGED
|
@@ -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
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* group
|
|
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
|
|
143
|
-
* group: the bar shrinks
|
|
144
|
-
*
|
|
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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* the
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
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
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
463
|
-
//
|
|
464
|
-
//
|
|
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
|
|
573
|
-
//
|
|
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
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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) {
|
package/src/render/text.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
* (U+
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
used
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|