Skip to content

Commit 87bbae8

Browse files
committed
fix: improve terminal output
- feature: added support for erase to beginning of line and erase line, so that the prefix is correctly rendered. - fix: bug where \r would result in the prefix being rendered over process output. - fix: bug where output was not flushed
1 parent 30bbdcb commit 87bbae8

File tree

2 files changed

+83
-24
lines changed

2 files changed

+83
-24
lines changed

engine/csi/csi.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,15 @@ func (r *Reader) Read() (Segment, error) {
7676
}
7777
}
7878

79-
// Segment is a segment of a terminal stream.
79+
// Segment of a terminal stream, either [CSI] or [Text].
8080
//
8181
//sumtype:decl
82-
type Segment interface{ segment() }
82+
type Segment interface {
83+
segment()
84+
String() string
85+
}
86+
87+
var _ Segment = CSI{}
8388

8489
// CSI represents an escape sequence.
8590
type CSI struct {
@@ -103,11 +108,14 @@ func (c CSI) IntParams() ([]int, error) {
103108
return out, nil
104109
}
105110

106-
func (c *CSI) String() string {
111+
func (c CSI) String() string {
107112
return fmt.Sprintf("\033[%s%s%c", c.Params, c.Intermediate, c.Final)
108113
}
109114

115+
var _ Segment = Text{}
116+
110117
// Text is a text segment.
111118
type Text []byte
112119

113-
func (t Text) segment() {}
120+
func (t Text) segment() {}
121+
func (t Text) String() string { return string(t) }

engine/logging/logger.go

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import (
77
"os"
88
"os/exec"
99
"os/signal"
10-
"runtime"
1110
"strings"
11+
"sync"
1212
"syscall"
1313

1414
"github.com/alecthomas/bit/engine/csi"
@@ -201,6 +201,7 @@ func (l *Logger) Exec(dir, command string) error {
201201
defer t.Close()
202202
defer p.Close()
203203
lw := l.WriterAt(LogLevelInfo)
204+
defer lw.Close()
204205
cmd := exec.Command("/bin/sh", "-c", command)
205206
cmd.Dir = dir
206207
cmd.SysProcAttr = &syscall.SysProcAttr{
@@ -218,55 +219,105 @@ func (l *Logger) Exec(dir, command string) error {
218219
}
219220

220221
// WriterAt returns a writer that logs each line at the given level.
221-
func (l *Logger) WriterAt(level LogLevel) *io.PipeWriter {
222+
func (l *Logger) WriterAt(level LogLevel) io.WriteCloser {
222223
// Based on MIT licensed Logrus https://github.com/sirupsen/logrus/blob/bdc0db8ead3853c56b7cd1ac2ba4e11b47d7da6b/writer.go#L27
223224
reader, writer := io.Pipe()
224225

225-
go l.writerScanner(reader, level)
226-
runtime.SetFinalizer(writer, writerFinalizer)
226+
wg := &sync.WaitGroup{}
227+
wg.Add(1)
228+
go l.writerScanner(wg, reader, level)
229+
return &pipeWait{r: reader, PipeWriter: writer, wg: wg}
230+
}
231+
232+
type pipeWait struct {
233+
r *io.PipeReader
234+
*io.PipeWriter
235+
wg *sync.WaitGroup
236+
}
227237

228-
return writer
238+
func (p pipeWait) Close() error {
239+
err := p.PipeWriter.Close()
240+
p.wg.Wait()
241+
return err
229242
}
230243

231244
// There is a bit of magic here to support cursor horizontal absolute escape
232245
// sequences. When a new position is requested, we add the width of the margin.
233-
func (l *Logger) writerScanner(r *io.PipeReader, level LogLevel) {
246+
func (l *Logger) writerScanner(wg *sync.WaitGroup, r *io.PipeReader, level LogLevel) {
234247
defer r.Close()
248+
defer wg.Done()
249+
250+
w, _ := os.OpenFile("foo", os.O_WRONLY|os.O_APPEND, 0600)
251+
defer w.Close()
235252

236253
esc := csi.NewReader(r)
254+
drawPrefix := true
255+
var newline []byte
237256
for {
238257
segment, err := esc.Read()
239258
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) {
259+
os.Stdout.Write(newline)
240260
return
241261
} else if err != nil {
242262
l.Warnf("error reading CSI sequence: %s", err)
243263
return
244264
}
265+
266+
// If we have a CSI sequence, possibly intercept it to cater for the margin.
267+
// But in all cases we want to transform it to Text.
245268
if cs, ok := segment.(csi.CSI); ok {
246-
if cs.Final == 'G' {
247-
params, err := cs.IntParams()
248-
if err != nil || len(params) != 1 {
249-
segment = csi.Text(cs.String())
250-
} else {
269+
// All the cases we intercept are single parameter sequences.
270+
params, err := cs.IntParams()
271+
if err != nil || len(params) != 1 {
272+
segment = csi.Text(cs.String())
273+
} else {
274+
switch cs.Final {
275+
case 'G': // G is cursor horizontal absolute.
251276
// We have a CHA sequence, so add the margin width.
252-
col := params[0]
253-
col += 18
277+
col := params[0] + 18
254278
segment = csi.Text(fmt.Sprintf("\033[%dG", col))
279+
280+
case 'K': // K is erase in line. We want to intercept 1 (clear to start of line) and 2 (clear entire line).
281+
if params[0] == 1 || params[0] == 2 {
282+
// Save the cursor position.
283+
text := []byte("\033[s")
284+
// Apply the CSI.
285+
text = append(text, cs.String()...)
286+
// Move to the start of the line.
287+
text = append(text, "\033[1G"...)
288+
// Write the prefix.
289+
text = append(text, l.getPrefix(level)...)
290+
// Restore the cursor position.
291+
text = append(text, "\033[u"...)
292+
293+
segment = csi.Text(text)
294+
} else {
295+
segment = csi.Text(cs.String())
296+
}
297+
default:
298+
segment = csi.Text(cs.String())
255299
}
256-
} else {
257-
segment = csi.Text(cs.String())
258300
}
259301
}
260302

261303
for _, b := range segment.(csi.Text) { //nolint:forcetypeassert
262-
os.Stdout.Write([]byte{b})
304+
w.Write([]byte{b}) //nolint:errcheck
263305
if b == '\r' || b == '\n' {
306+
newline = append(newline, b)
307+
continue
308+
}
309+
if drawPrefix {
264310
os.Stdout.Write([]byte(l.getPrefix(level)))
311+
drawPrefix = false
265312
}
313+
for _, nl := range newline {
314+
os.Stdout.Write([]byte{nl})
315+
if nl == '\n' {
316+
os.Stdout.Write([]byte(l.getPrefix(level)))
317+
}
318+
}
319+
newline = nil
320+
os.Stdout.Write([]byte{b})
266321
}
267322
}
268323
}
269-
270-
func writerFinalizer(writer *io.PipeWriter) {
271-
_ = writer.Close()
272-
}

0 commit comments

Comments
 (0)