Skip to content

Commit f23e153

Browse files
committed
fix: shrink whitespace lines
1 parent b4cde52 commit f23e153

File tree

2 files changed

+290
-8
lines changed

2 files changed

+290
-8
lines changed
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// @ts-ignore: bun:test types aren't available
2+
import { describe, expect, it } from 'bun:test'
3+
import { gray } from 'picocolors'
4+
import { onlyWhitespace, squashNewlines } from '../display'
5+
6+
const PREFIX = '.\r\n'
7+
8+
// Helper function to simulate getLastTwoLines behavior
9+
function getLastTwoLines(str: string): string {
10+
return PREFIX + str.split('\r\n').slice(-2).join('\r\n')
11+
}
12+
13+
describe('squashNewlines', () => {
14+
describe('when called with getLastTwoLines(previous) + chunk', () => {
15+
it('should handle simple strings', () => {
16+
const previous = 'line1\r\nline2\r\nline3'
17+
const chunk = '\r\nline4\r\nline5'
18+
const lastTwoLines = getLastTwoLines(previous)
19+
const combined = lastTwoLines + chunk
20+
const squashed = squashNewlines(combined)
21+
22+
expect(squashed).toEqual(lastTwoLines + chunk)
23+
})
24+
25+
it('should handle when chunk has empty lines', () => {
26+
const previous = 'content\r\nmore content'
27+
const chunk = '\r\n\r\n\r\nfinal line'
28+
const lastTwoLines = getLastTwoLines(previous)
29+
const combined = lastTwoLines + chunk
30+
const squashed = squashNewlines(combined)
31+
32+
expect(squashed).toEqual(lastTwoLines + '\r\n\r\nfinal line')
33+
})
34+
35+
it('should handle when chunk has whitespace lines', () => {
36+
const previous = 'first\r\nsecond\r\nthird'
37+
const chunk = '\r\n \r\n\t\r\nfourth'
38+
const lastTwoLines = getLastTwoLines(previous)
39+
const combined = lastTwoLines + chunk
40+
const squashed = squashNewlines(combined)
41+
42+
expect(squashed).toEqual(lastTwoLines + '\r\n \r\n\tfourth')
43+
})
44+
45+
it('should handle when previous is empty', () => {
46+
const previous = ''
47+
const chunk = 'some\r\ncontent'
48+
const lastTwoLines = getLastTwoLines(previous)
49+
const combined = lastTwoLines + chunk
50+
const squashed = squashNewlines(combined)
51+
52+
expect(squashed).toEqual(lastTwoLines + chunk)
53+
})
54+
55+
it('should handle when chunk is empty', () => {
56+
const previous = 'some\r\ncontent\r\nhere'
57+
const chunk = ''
58+
const lastTwoLines = getLastTwoLines(previous)
59+
const combined = lastTwoLines + chunk
60+
const squashed = squashNewlines(combined)
61+
62+
expect(squashed).toEqual(lastTwoLines + chunk)
63+
})
64+
65+
it('should handle when both strings are empty', () => {
66+
const previous = ''
67+
const chunk = ''
68+
const lastTwoLines = getLastTwoLines(previous)
69+
const combined = lastTwoLines + chunk
70+
const squashed = squashNewlines(combined)
71+
72+
expect(squashed).toEqual(lastTwoLines + chunk)
73+
})
74+
75+
it('should handle complex mixed content', () => {
76+
const previous = 'alpha\r\nbeta\r\ngamma\r\ndelta'
77+
const chunk = '\r\n\r\n \r\n\t\r\n\r\nepsilon\r\nzeta'
78+
const lastTwoLines = getLastTwoLines(previous)
79+
const combined = lastTwoLines + chunk
80+
const squashed = squashNewlines(combined)
81+
82+
expect(squashed).toEqual(lastTwoLines + '\r\n\r\n \tepsilon\r\nzeta')
83+
})
84+
85+
it('should handle when previous has only newlines', () => {
86+
const previous = '\r\n\r\n\r\n'
87+
const chunk = 'content'
88+
const lastTwoLines = getLastTwoLines(previous)
89+
const combined = lastTwoLines + chunk
90+
const squashed = squashNewlines(combined)
91+
92+
expect(squashed).toEqual(lastTwoLines + chunk)
93+
})
94+
95+
it('should handle when chunk has only newlines', () => {
96+
const previous = 'content\r\nmore'
97+
const chunk = '\r\n\r\n\r\n'
98+
const lastTwoLines = getLastTwoLines(previous)
99+
const combined = lastTwoLines + chunk
100+
const squashed = squashNewlines(combined)
101+
102+
expect(squashed).toEqual(lastTwoLines + '\r\n\r\n')
103+
})
104+
105+
it('should handle single line inputs', () => {
106+
const previous = 'single line'
107+
const chunk = 'another line'
108+
const lastTwoLines = getLastTwoLines(previous)
109+
const combined = lastTwoLines + chunk
110+
const squashed = squashNewlines(combined)
111+
112+
expect(squashed).toEqual(lastTwoLines + chunk)
113+
})
114+
115+
it('should handle previous ends with whitespace lines', () => {
116+
const previous = 'content\r\n \r\n\t'
117+
const chunk = '\r\nmore content'
118+
const lastTwoLines = getLastTwoLines(previous)
119+
const combined = lastTwoLines + chunk
120+
const squashed = squashNewlines(combined)
121+
122+
expect(squashed).toEqual(lastTwoLines + 'more content')
123+
})
124+
})
125+
126+
describe('squashNewlines behavior verification', () => {
127+
it('should squash consecutive empty lines correctly', () => {
128+
const input = PREFIX + 'line1\r\n\r\n\r\n\r\nline5'
129+
const result = squashNewlines(input)
130+
131+
// Should reduce multiple consecutive empty lines to at most 2
132+
expect(result).toBe(PREFIX + 'line1\r\n\r\nline5')
133+
})
134+
135+
it('should preserve single empty lines', () => {
136+
const input = PREFIX + 'line1\r\n\r\nline3'
137+
const result = squashNewlines(input)
138+
139+
expect(result).toBe(input) // Should remain unchanged
140+
})
141+
})
142+
143+
describe('error handling', () => {
144+
it('should throw error when input does not start with PREFIX', () => {
145+
const invalidInput = 'invalid input without prefix'
146+
147+
expect(() => squashNewlines(invalidInput)).toThrow(
148+
`Expected string to start with ${JSON.stringify(PREFIX)}`
149+
)
150+
})
151+
})
152+
})
153+
154+
describe('onlyWhitespace', () => {
155+
it('should return true for empty string', () => {
156+
expect(onlyWhitespace('')).toBe(true)
157+
})
158+
159+
it('should return true for single space', () => {
160+
expect(onlyWhitespace(' ')).toBe(true)
161+
})
162+
163+
it('should return true for multiple spaces', () => {
164+
expect(onlyWhitespace(' ')).toBe(true)
165+
})
166+
167+
it('should return true for tab', () => {
168+
expect(onlyWhitespace('\t')).toBe(true)
169+
})
170+
171+
it('should return true for newline', () => {
172+
expect(onlyWhitespace('\n')).toBe(true)
173+
})
174+
175+
it('should return true for carriage return', () => {
176+
expect(onlyWhitespace('\r')).toBe(true)
177+
})
178+
179+
it('should return true for ANSI escape sequences', () => {
180+
expect(onlyWhitespace('\u001b[31m')).toBe(true) // Red color
181+
expect(onlyWhitespace('\u001b[0m')).toBe(true) // Reset
182+
})
183+
184+
it('should return true for OSC sequences', () => {
185+
const oscSequence =
186+
'\u001b]697;OSCUnlock=683fe5e7c2d2476bb61d4e0588c15eec\u0007\u001b]697;Dir=/Users/jahooma/codebuff\u0007'
187+
expect(onlyWhitespace(oscSequence)).toBe(true)
188+
})
189+
190+
it('should return false for control characters', () => {
191+
expect(onlyWhitespace('\u0000\u0001\u0002')).toBe(true) // Null, SOH, STX
192+
expect(onlyWhitespace('\u007F')).toBe(true) // DEL
193+
})
194+
195+
it('should return true for zero-width characters', () => {
196+
expect(onlyWhitespace('\u200B')).toBe(true)
197+
expect(onlyWhitespace('\u200C')).toBe(true)
198+
expect(onlyWhitespace('\u200D')).toBe(true)
199+
})
200+
201+
it('should return true for colored empty strings', () => {
202+
expect(onlyWhitespace(gray(' '))).toBe(true)
203+
})
204+
205+
it('should return false for visible text', () => {
206+
expect(onlyWhitespace('hello')).toBe(false)
207+
expect(onlyWhitespace('a')).toBe(false)
208+
expect(onlyWhitespace('123')).toBe(false)
209+
})
210+
211+
describe('real world examples', () => {
212+
it('should return false for end of terminal command', () => {
213+
expect(
214+
onlyWhitespace(
215+
'\u001b]697;OSCUnlock=683fe5e7c2d2476bb61d4e0588c15eec\u0007\u001b]697;Dir=/Users/jahooma/codebuff\u0007\u001b]697;Shell=bash\u0007\u001b]697;ShellPath=/bin/bash\u0007\u001b]697;PID=71631\u0007\u001b]697;ExitCode=0\u0007\u001b]697;TTY=/dev/ttys036\u0007\u001b]697;Log=\u0007\u001b]697;User=jahooma\u0007\u001b]697;OSCLock=683fe5e7c2d2476bb61d4e0588c15eec\u0007\u001b]697;PreExec\u0007\u001b]697;StartPrompt\u0007'
216+
)
217+
).toBe(true)
218+
219+
expect(
220+
onlyWhitespace(
221+
'\u001b]0;charles@charles-framework-13:~/github/codebuff\u001b\\\u001b]7;file://charles-framework-13/home/charles/github/codebuff\u001b\\\u001b[?2004h'
222+
)
223+
).toBe(true)
224+
})
225+
})
226+
})

npm-app/src/display.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
* renders as four newline characters. Because there is an ANSI escape
66
* character between the first two and the last two newline characters.
77
*/
8+
import stringWidth from 'string-width'
89

10+
const PREFIX = '.\r\n'
911
let squashingEnabled = false
10-
let previous = ' '
12+
let previous = PREFIX
1113

1214
export function getPrevious(): string {
1315
return previous
@@ -25,14 +27,68 @@ export function disableSquashNewlines(): void {
2527
squashingEnabled = false
2628
}
2729

30+
/** OSC … BEL | ST (titles, hyperlinks, cwd hints, etc.) */
31+
const OSC = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g
32+
33+
/** CSI … final-byte (cursor moves, ?2004h, colours if stripAnsi missed them) */
34+
const CSI = /\u001B\[[0-?]*[ -/]*[@-~]/g
35+
36+
/** Zero-width Unicode code-points (format, combining, enclosing) */
37+
const ZW = /[\p{Cf}\p{Mn}\p{Me}]/gu
38+
39+
/**
40+
* `true` → after stripping VT controls and whitespace the string has zero width
41+
*/
42+
export function onlyWhitespace(raw: string): boolean {
43+
const visible = raw
44+
.replace(OSC, '') // remove OSC 0/7/8/133/697/…
45+
.replace(CSI, '') // remove CSI H, A, ?2004h, …
46+
.replace(/\s+/g, '') // remove spaces, tabs, CR, LF
47+
.replace(ZW, '') // remove ZWJ, ZWNJ, VS16, etc.
48+
49+
return stringWidth(visible) === 0
50+
}
51+
2852
function addCarriageReturn(str: string): string {
2953
// Do not copy over \n from previous
30-
const base = (previous[previous.length - 1] === '\r' ? '\r' : ' ') + str
54+
const base = (previous[previous.length - 1] === '\r' ? '\r' : '.') + str
3155
// Replace twice, because of no overlap '\n\n'
3256
const withCarriageReturns = base.replace(/(?<!\r)\n/g, '\r\n')
3357
return withCarriageReturns.slice(1)
3458
}
3559

60+
function getLastTwoLines(str: string): string {
61+
return PREFIX + str.split('\r\n').slice(-2).join('\r\n')
62+
}
63+
64+
export function squashNewlines(str: string): string {
65+
if (!str.startsWith(PREFIX)) {
66+
throw new Error(`Expected string to start with ${JSON.stringify(PREFIX)}`)
67+
}
68+
69+
const lines = str
70+
.split('\r\n')
71+
.map((line) => ({ line, empty: onlyWhitespace(line) }))
72+
73+
const agg: string[] = []
74+
let consecutiveEmptyLines = 0
75+
for (const { line, empty } of lines) {
76+
if (consecutiveEmptyLines > 1) {
77+
agg[agg.length - 1] += line
78+
} else {
79+
agg.push(line)
80+
}
81+
82+
if (empty) {
83+
consecutiveEmptyLines++
84+
} else {
85+
consecutiveEmptyLines = 0
86+
}
87+
}
88+
89+
return agg.join('\r\n')
90+
}
91+
3692
const originalWrite = process.stdout.write.bind(process.stdout)
3793

3894
process.stdout.write = function (
@@ -47,7 +103,7 @@ process.stdout.write = function (
47103

48104
if (!squashingEnabled) {
49105
previous += chunkString
50-
previous = previous.slice(previous.length - 4)
106+
previous = getLastTwoLines(previous)
51107

52108
if (typeof encodingOrCallback === 'function') {
53109
// Called like write(chunk, callback)
@@ -58,9 +114,9 @@ process.stdout.write = function (
58114
}
59115

60116
const combinedContent = previous + chunkString
61-
const processedContent = combinedContent.replace(/(\r\n){3,}/g, '\r\n\r\n')
117+
const processedContent = squashNewlines(combinedContent)
62118
const processedChunk = processedContent.slice(previous.length)
63-
previous = processedContent.slice(processedContent.length - 4)
119+
previous = getLastTwoLines(processedContent)
64120

65121
if (typeof encodingOrCallback === 'function') {
66122
// Called like write(chunk, callback)
@@ -84,7 +140,7 @@ process.stderr.write = function (
84140

85141
if (!squashingEnabled) {
86142
previous += chunkString
87-
previous = previous.slice(previous.length - 4)
143+
previous = getLastTwoLines(previous)
88144

89145
if (typeof encodingOrCallback === 'function') {
90146
// Called like write(chunk, callback)
@@ -95,9 +151,9 @@ process.stderr.write = function (
95151
}
96152

97153
const combinedContent = previous + chunkString
98-
const processedContent = combinedContent.replace(/\r\n{3,}/g, '\r\n\r\n')
154+
const processedContent = squashNewlines(combinedContent)
99155
const processedChunk = processedContent.slice(previous.length)
100-
previous = processedContent.slice(processedContent.length - 4)
156+
previous = getLastTwoLines(processedContent)
101157

102158
if (typeof encodingOrCallback === 'function') {
103159
// Called like write(chunk, callback)

0 commit comments

Comments
 (0)