Skip to content

Commit f525744

Browse files
committed
Fix background log previews
1 parent 4720e54 commit f525744

5 files changed

Lines changed: 244 additions & 39 deletions

File tree

extensions/background.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,22 @@
99

1010
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
1111
import { DynamicBorder, truncateTail } from '@earendil-works/pi-coding-agent'
12-
import { Container, Text } from '@earendil-works/pi-tui'
12+
import { Container, Text, visibleWidth } from '@earendil-works/pi-tui'
1313
import { errorMessage } from './shared/errors'
1414
import {
1515
firstText,
1616
meta,
17-
primary,
1817
renderError,
1918
renderLines,
2019
renderMuted,
20+
renderTextLinesPreview,
2121
renderToolCall,
2222
title,
23+
truncateLine,
2324
toolError,
2425
toolText
2526
} from './shared/render'
27+
import { compactLines, normalizeTerminalOutput } from './shared/format'
2628
import { Type } from 'typebox'
2729
import { spawn, spawnSync } from 'child_process'
2830
import * as crypto from 'crypto'
@@ -223,22 +225,6 @@ function stopProcess(projectDir: string, name: string): void {
223225
}
224226
}
225227

226-
function stripProgressNoise(text: string): string {
227-
// eslint-disable-next-line no-control-regex
228-
let clean = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
229-
230-
clean = clean
231-
.split('\n')
232-
.map((line) => {
233-
if (!line.includes('\r')) return line
234-
const parts = line.split('\r')
235-
return parts[parts.length - 1]
236-
})
237-
.join('\n')
238-
239-
return clean
240-
}
241-
242228
function readLogs(projectDir: string, name: string, lines: number): string {
243229
const dir = findProcessDir(projectDir, name)
244230
if (!dir) {
@@ -254,7 +240,7 @@ function readLogs(projectDir: string, name: string, lines: number): string {
254240
encoding: 'utf8'
255241
})
256242

257-
const raw = stripProgressNoise(result.stdout || result.stderr || '')
243+
const raw = normalizeTerminalOutput(result.stdout || result.stderr || '')
258244
const truncation = truncateTail(raw, { maxLines: lines })
259245

260246
if (truncation.truncated) {
@@ -364,15 +350,39 @@ function updateStatus(ctx: ExtensionContext) {
364350
for (const proc of running) {
365351
const displayName = getDisplayName(proc)
366352
try {
367-
const logs = readLogs(ctx.cwd, proc.name, 2)
368-
container.addChild(new Text(theme.fg('muted', ` ${displayName} `), 0, 0))
369-
if (logs.trim()) {
370-
for (const line of logs.trim().split('\n')) {
371-
container.addChild(new Text(theme.fg('dim', ` ${line}`), 0, 0))
372-
}
353+
const logs = readLogs(ctx.cwd, proc.name, 10)
354+
const lines = compactLines(logs)
355+
const latest = lines.at(-1)
356+
const hidden = Math.max(0, lines.length - 1)
357+
container.addChild({
358+
render: (width) => [
359+
truncateLine(theme.fg('muted', ` ${displayName} `), width, theme.fg('muted', '…'))
360+
],
361+
invalidate: () => undefined
362+
})
363+
if (latest) {
364+
container.addChild({
365+
render: (width) => {
366+
const suffix = hidden > 0 ? ` ${theme.fg('muted', `… ${hidden} more lines`)}` : ''
367+
const suffixWidth = visibleWidth(suffix)
368+
if (suffixWidth >= width)
369+
return [truncateLine(suffix, width, theme.fg('muted', '…'))]
370+
const available = Math.max(1, width - suffixWidth)
371+
return [
372+
truncateLine(theme.fg('dim', ` ${latest}`), available, theme.fg('dim', '…')) +
373+
suffix
374+
]
375+
},
376+
invalidate: () => undefined
377+
})
373378
}
374379
} catch {
375-
container.addChild(new Text(theme.fg('muted', ` ${displayName} `), 0, 0))
380+
container.addChild({
381+
render: (width) => [
382+
truncateLine(theme.fg('muted', ` ${displayName} `), width, theme.fg('muted', '…'))
383+
],
384+
invalidate: () => undefined
385+
})
376386
container.addChild(new Text(theme.fg('dim', ' (no logs)'), 0, 0))
377387
}
378388
}
@@ -635,20 +645,23 @@ export default function (pi: ExtensionAPI) {
635645
const safeArgs = args ?? {}
636646
return renderToolCall(theme, 'bg logs', {
637647
segments: [{ text: safeArgs.name }],
638-
suffix: safeArgs.lines ? `${safeArgs.lines} lines` : undefined
648+
suffix: safeArgs.lines ? `last ${safeArgs.lines}` : undefined
639649
})
640650
},
641651

642-
renderResult(result, _options, theme) {
652+
renderResult(result, { expanded }, theme) {
643653
const details = result.details as LogsDetails
644654
if (details.error) return renderError(firstText(result, 'Error'), theme)
645-
if (!details.logs.trim()) return renderMuted('(empty)', theme)
646-
const preview = details.logs.split('\n').slice(-3)
647-
return renderLines([
648-
...renderProcessRow(theme, details.name, `logs last ${preview.length}`),
649-
'',
650-
...preview.map((line) => primary(line, theme))
651-
])
655+
const lines = compactLines(details.logs)
656+
if (lines.length === 0) return renderMuted('(empty)', theme)
657+
return renderTextLinesPreview(lines, theme, {
658+
expanded,
659+
compactLimit: 3,
660+
mode: 'tail',
661+
hiddenUnit: 'hidden',
662+
inlineHidden: true,
663+
truncationMarker: theme.fg('toolOutput', '…')
664+
})
652665
}
653666
})
654667
}

extensions/shared/format.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, expect, test } from 'vitest'
22

3-
import { compactText, formatBytes, formatDuration } from './format'
3+
import {
4+
compactLines,
5+
compactText,
6+
formatBytes,
7+
formatDuration,
8+
normalizeTerminalOutput,
9+
stripAnsi,
10+
stripCarriageReturnProgress
11+
} from './format'
412

513
describe('formatBytes', () => {
614
test('formats byte counts for human metadata', () => {
@@ -22,3 +30,21 @@ describe('compactText', () => {
2230
expect(compactText('hello world', 6)).toBe('hello…')
2331
})
2432
})
33+
34+
describe('terminal output helpers', () => {
35+
test('strips ansi escape sequences', () => {
36+
expect(stripAnsi('\u001b[31mred\u001b[0m')).toBe('red')
37+
})
38+
39+
test('keeps the final carriage-return progress frame', () => {
40+
expect(stripCarriageReturnProgress('10%\r20%\ndone')).toBe('20%\ndone')
41+
})
42+
43+
test('normalizes terminal output', () => {
44+
expect(normalizeTerminalOutput('\u001b[32m10%\r20%\u001b[0m')).toBe('20%')
45+
})
46+
47+
test('compacts non-empty terminal lines', () => {
48+
expect(compactLines('one\n\n two\t three ', 20)).toEqual(['one', 'two three'])
49+
})
50+
})

extensions/shared/format.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,35 @@ export function compactText(text: string, maxChars: number): string {
1515
const compact = text.replace(/\s+/gu, ' ').trim()
1616
return compact.length > maxChars ? `${compact.slice(0, Math.max(0, maxChars - 1))}…` : compact
1717
}
18+
19+
export function stripAnsi(text: string): string {
20+
// eslint-disable-next-line no-control-regex
21+
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
22+
}
23+
24+
export function stripCarriageReturnProgress(text: string): string {
25+
return text
26+
.split('\n')
27+
.map((line) => {
28+
if (!line.includes('\r')) return line
29+
const parts = line.split('\r')
30+
return parts[parts.length - 1] ?? ''
31+
})
32+
.join('\n')
33+
}
34+
35+
export function normalizeTerminalOutput(text: string): string {
36+
return stripCarriageReturnProgress(stripAnsi(text))
37+
}
38+
39+
export function compactLines(
40+
text: string,
41+
maxChars = Number.MAX_SAFE_INTEGER,
42+
options: { normalizeTerminal?: boolean } = {}
43+
): string[] {
44+
const content = options.normalizeTerminal === false ? text : normalizeTerminalOutput(text)
45+
return content
46+
.split('\n')
47+
.map((line) => compactText(line, maxChars))
48+
.filter(Boolean)
49+
}

extensions/shared/render.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
renderErrorOrPartial,
1111
renderLines,
1212
renderMarkdownPreview,
13+
renderTextLinesPreview,
1314
renderToolCall,
1415
toolError,
1516
toolLoading
@@ -35,6 +36,17 @@ describe('renderLines', () => {
3536
expect(visibleWidth(line)).toBeLessThanOrEqual(10)
3637
expect(line).toContain('…')
3738
})
39+
40+
test('supports explicit truncation marker', () => {
41+
const [, line] = renderTextLinesPreview(['abcdefghijklmnopqrstuvwxyz'], theme, {
42+
expanded: false,
43+
compactLimit: 1,
44+
truncationMarker: '...'
45+
}).render(10)
46+
47+
expect(visibleWidth(line)).toBeLessThanOrEqual(10)
48+
expect(line).toContain('...')
49+
})
3850
})
3951

4052
describe('renderErrorOrPartial', () => {
@@ -111,6 +123,56 @@ describe('renderMarkdownPreview', () => {
111123
})
112124
})
113125

126+
describe('renderTextLinesPreview', () => {
127+
test('renders a compact tail preview with expand footer', () => {
128+
const lines = renderTextLinesPreview(['one', 'two', 'three'], theme, {
129+
expanded: false,
130+
compactLimit: 1,
131+
header: ['logs'],
132+
mode: 'tail'
133+
}).render(120)
134+
135+
expect(lines).toContain('logs')
136+
expect(lines).toContain('three')
137+
expect(lines).toContain('… 2 more lines')
138+
expect(lines.join('\n')).toContain('ctrl+o')
139+
expect(lines).not.toContain('one')
140+
})
141+
142+
test('renders all lines when expanded', () => {
143+
const lines = renderTextLinesPreview(['one', 'two'], theme, {
144+
expanded: true,
145+
compactLimit: 1,
146+
mode: 'tail'
147+
}).render(120)
148+
149+
expect(lines).toEqual(['', 'one', 'two'])
150+
})
151+
152+
test('supports inline hidden counts without extra footer lines', () => {
153+
const lines = renderTextLinesPreview(['one', 'two', 'three'], theme, {
154+
expanded: false,
155+
compactLimit: 1,
156+
header: ['logs'],
157+
mode: 'tail',
158+
inlineHidden: true
159+
}).render(120)
160+
161+
expect(lines).toEqual(['', 'logs', 'three … 2 more lines (ctrl+o to expand)'])
162+
})
163+
164+
test('limits expanded previews when requested', () => {
165+
const lines = renderTextLinesPreview(['one', 'two', 'three', 'four'], theme, {
166+
expanded: true,
167+
compactLimit: 1,
168+
expandedLimit: 3,
169+
mode: 'tail'
170+
}).render(120)
171+
172+
expect(lines).toEqual(['', 'two', 'three', 'four'])
173+
})
174+
})
175+
114176
describe('renderEntryList', () => {
115177
test('renders compact entries with hidden footer', () => {
116178
const lines = renderEntryList(['one', 'two'], theme, {

0 commit comments

Comments
 (0)