Skip to content

Add a "lean" TUI#2232

Closed
rumpl wants to merge 1 commit intodocker:mainfrom
rumpl:lean-tui
Closed

Add a "lean" TUI#2232
rumpl wants to merge 1 commit intodocker:mainfrom
rumpl:lean-tui

Conversation

@rumpl
Copy link
Member

@rumpl rumpl commented Mar 24, 2026

  • no alternate screen
  • no bells, no whistles, only assistant messages and tool calls

With diffing renderer that only updates what need to be updated.

I am very unsure about the name...

Kapture.2026-03-24.at.20.14.21.mp4

- no alternate screen
- no bells, no whistles, only assistant messages and tool calls

Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
@rumpl rumpl requested a review from a team as a code owner March 24, 2026 19:17
@rumpl rumpl closed this Mar 24, 2026
Copy link

@docker-agent docker-agent bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🔴 CRITICAL

This PR introduces several critical concurrency bugs and compilation errors that must be fixed before merging.

}

func (e *Editor) HandleInput(data string) {
if strings.HasPrefix(data, pastePrefix) && strings.HasSuffix(data, pasteSuffix) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH SEVERITY - CONFIRMED

Undefined constants pastePrefix and pasteSuffix

This code references constants that are never defined, causing a compilation error:

if strings.HasPrefix(data, pastePrefix) && strings.HasSuffix(data, pasteSuffix) {
    e.insertString(strings.TrimSuffix(strings.TrimPrefix(data, pastePrefix), pasteSuffix))

Impact: Code will not compile.

Fix: Define these constants (likely ANSI bracketed paste sequences):

const (
    pastePrefix = "\x1b[200~"
    pasteSuffix = "\x1b[201~"
)

}

func (t *ProcessTerminal) emit(data string) {
if t.onInput != nil {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH SEVERITY - CONFIRMED

Data race on onInput field

The onInput callback is accessed concurrently without synchronization:

  • Written in Start() (line 58) and DrainInput() (lines 105-107)
  • Read in emit() (line 238) from the readLoop goroutine

Impact: Race detector will fail; potential crashes or missed input events.

Fix: Protect with a mutex:

type ProcessTerminal struct {
    mu       sync.RWMutex
    onInput  func(string)
    onResize func()
    // ...
}

func (t *ProcessTerminal) emit(data string) {
    t.mu.RLock()
    handler := t.onInput
    t.mu.RUnlock()
    if handler != nil {
        handler(data)
    }
}

w, h := t.Columns(), t.Rows()
if w != lastW || h != lastH {
lastW, lastH = w, h
if t.onResize != nil {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH SEVERITY - CONFIRMED

Data race on onResize field

The onResize callback is accessed concurrently without synchronization:

  • Written in Start() (line 59)
  • Read in resizeLoop() (line 167) from a separate goroutine

Impact: Race detector will fail; potential crashes or missed resize events.

Fix: Use the same mutex as onInput (see comment on line 238).

return lines
}

func (t *TUI) doRender() {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH SEVERITY - CONFIRMED

Data race on rendering state fields

The doRender() method reads and writes multiple fields (lines, cursorRow, cursorCol, viewportStart, etc.) without mutex protection, while these fields can be modified by other methods like SetContent() and MoveCursor().

Impact: Race detector will fail; potential rendering corruption or crashes.

Fix: Add a mutex to the TUI struct and lock it in doRender():

type TUI struct {
    mu sync.Mutex
    // ... existing fields
}

func (t *TUI) doRender() {
    t.mu.Lock()
    defer t.mu.Unlock()
    // ... existing logic
}

Also protect other methods that modify these fields.

default:
return false
}
_ = a.application.ResumeElicitation(context.Background(), action, nil)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM SEVERITY - CONFIRMED

ResumeElicitation uses context.Background() instead of app context

This call ignores the app's cancellation context and discards errors:

_ = a.application.ResumeElicitation(context.Background(), action, nil)

Impact: Operation won't be cancelled when the app shuts down; errors are silently ignored.

Fix: Use the app's context and log errors:

if err := a.application.ResumeElicitation(a.ctx, action, nil); err != nil {
    log.Printf("ResumeElicitation failed: %v", err)
}

var buffer strings.Builder
buffer.WriteString("\x1b[?2026h")
prevViewportBottom := prevViewportTop + height - 1
moveTargetRow := firstChanged
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM SEVERITY - CONFIRMED

Negative moveTargetRow when appendStart=true and firstChanged=0

When appendStart is true, the code decrements firstChanged:

moveTargetRow := firstChanged
if appendStart {
    moveTargetRow--
}

If firstChanged is 0, moveTargetRow becomes -1, which is invalid for ANSI cursor positioning.

Impact: Incorrect terminal output or rendering glitches.

Fix: Clamp to 0:

moveTargetRow := maxInt(0, firstChanged)
if appendStart && moveTargetRow > 0 {
    moveTargetRow--
}

viewportTop := maxInt(0, totalLines-height)
targetRow := pos.Row
currentScreenRow := t.hardwareCursorRow - viewportTop
targetScreenRow := targetRow - viewportTop
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM SEVERITY - CONFIRMED

Negative screen row in positionHardwareCursor

When the cursor is above the viewport, targetScreenRow can be negative:

targetScreenRow := targetRow - viewportTop
delta := targetScreenRow - currentScreenRow

Impact: Invalid ANSI escape sequences sent to the terminal.

Fix: Clamp to viewport bounds:

targetScreenRow := maxInt(0, minInt(height-1, targetRow - viewportTop))

for {
select {
case <-t.stopCh:
return
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM SEVERITY - LIKELY

Potential goroutine leak in readLoop

The readLoop goroutine may not exit cleanly if Stop() is called while blocked on I/O:

func (t *ProcessTerminal) readLoop() {
    for {
        select {
        case <-t.stopCh:
            return
        default:
        }
        // Blocking read - may not check stopCh for a long time
        data, err := t.reader.ReadString('\n')
        // ...
    }
}

Impact: Goroutine may not terminate until the next I/O operation completes.

Fix: Use a context-aware reader or set a read deadline:

t.pty.SetReadDeadline(time.Now().Add(100 * time.Millisecond))

runes := []rune(ansi.Strip(text))
half := (width - 1) / 2
head := string(runes[:minInt(len(runes), half)])
tailLen := min(len(runes), maxInt(1, width-ansi.StringWidth(head)-1))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM SEVERITY - LIKELY

Potential out-of-bounds slice in middleTruncate

When the text contains wide characters (e.g., emoji, CJK), ansi.StringWidth counts display width, not rune count. This can cause tailLen to exceed len(runes):

tailLen := min(len(runes), maxInt(1, width-ansi.StringWidth(head)-1))
tail := string(runes[len(runes)-tailLen:])

Impact: Panic on slice out-of-bounds.

Fix: Add a bounds check:

tailLen := min(len(runes), maxInt(1, width-ansi.StringWidth(head)-1))
if tailLen > len(runes) {
    tailLen = len(runes)
}
tail := string(runes[len(runes)-tailLen:])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant