Skip to content

Centralize ANSI escape sequences in console package#14340

Merged
pelikhan merged 2 commits intomainfrom
copilot/centralize-ansi-escape-sequences
Feb 7, 2026
Merged

Centralize ANSI escape sequences in console package#14340
pelikhan merged 2 commits intomainfrom
copilot/centralize-ansi-escape-sequences

Conversation

Copy link
Contributor

Copilot AI commented Feb 7, 2026

Manual ANSI escape codes were duplicated across 6 locations in pkg/cli and pkg/console. This centralizes them into constants and functions with proper TTY detection.

Changes

New module: pkg/console/terminal.go

  • Defines ANSI constants: ansiClearScreen, ansiClearLine, ansiCarriageReturn
  • Adds cursor movement: MoveCursorUp(n), MoveCursorDown(n) with TTY detection
  • All functions check tty.IsStderrTerminal() before emitting ANSI codes

Updated existing code:

  • pkg/cli/add_interactive_orchestrator.go: Uses console.ClearScreen() instead of inline \033[H\033[2J
  • pkg/console/console.go: ClearScreen() and ClearLine() now use constants from terminal.go
  • pkg/console/spinner.go: Three methods (render(), Stop(), StopWithMessage()) use constants

Bug fix:

  • ClearScreen() now writes to stderr (was stdout), consistent with other console operations

Example

Before:

fmt.Fprint(os.Stderr, "\033[H\033[2J")
fmt.Fprintf(m.output, "\r\033[K%s %s", m.spinner.View(), m.message)

After:

console.ClearScreen()
fmt.Fprintf(m.output, "%s%s%s %s", ansiCarriageReturn, ansiClearLine, m.spinner.View(), m.message)
Original prompt

This section details on the original issue you should resolve

<issue_title>[Code Quality] Centralize ANSI escape sequences in console package</issue_title>
<issue_description>## Description

Manual ANSI escape sequences are scattered across 8 locations in the codebase. Centralizing these in a dedicated module will reduce duplication, improve consistency, and make it easier to maintain TTY detection logic.

Current State

8 instances of manual ANSI codes found:

pkg/cli/add_interactive_orchestrator.go:  fmt.Fprint(os.Stderr, "\033[H\033[2J")
pkg/console/console.go:                   var clearScreenSequence = "\033[2J\033[H"
pkg/console/console.go:                   fmt.Fprint(os.Stderr, "\r\033[K")
pkg/console/spinner.go:                   fmt.Fprintf(m.output, "\r\033[K%s %s", ...)
pkg/console/spinner.go:                   fmt.Fprint(os.Stderr, "\r\033[K")
pkg/console/spinner.go:                   fmt.Fprintf(os.Stderr, "\r\033[K%s\n", msg)

Current issues:

  • ANSI codes duplicated in multiple files
  • No centralized TTY detection for these operations
  • Harder to maintain and update

Recommended Solution

Create pkg/console/terminal.go with helper functions:

package console

import (
    "fmt"
    "os"
    "github.com/github/gh-aw/pkg/tty"
)

// ClearScreen clears the terminal screen if stderr is a TTY
func ClearScreen() {
    if tty.IsStderrTerminal() {
        fmt.Fprint(os.Stderr, "\033[H\033[2J")
    }
}

// ClearLine clears the current line if stderr is a TTY
func ClearLine() {
    if tty.IsStderrTerminal() {
        fmt.Fprint(os.Stderr, "\r\033[K")
    }
}

// MoveCursorUp moves cursor up n lines if stderr is a TTY
func MoveCursorUp(n int) {
    if tty.IsStderrTerminal() {
        fmt.Fprintf(os.Stderr, "\033[%dA", n)
    }
}

// MoveCursorDown moves cursor down n lines if stderr is a TTY
func MoveCursorDown(n int) {
    if tty.IsStderrTerminal() {
        fmt.Fprintf(os.Stderr, "\033[%dB", n)
    }
}

Files Affected

  • Create: pkg/console/terminal.go (new helper functions)
  • Update: pkg/cli/add_interactive_orchestrator.go
  • Update: pkg/console/console.go
  • Update: pkg/console/spinner.go
  • Create: pkg/console/terminal_test.go (tests for new helpers)

Success Criteria

  • ✅ All ANSI escape sequences centralized in pkg/console/terminal.go
  • ✅ Helper functions respect TTY detection (no ANSI in pipes)
  • ✅ All 8 instances updated to use new helpers
  • ✅ Test coverage for new helper functions
  • ✅ All existing tests pass after refactoring
  • ✅ No functional changes (pure refactoring)
  • ✅ Build and lint checks pass (make build, make lint)

Source

Extracted from Terminal Stylist Report discussion github/gh-aw#12889

Finding: "Manual ANSI Escape Sequences" - 8 instances found
Recommendation: "Replace with Lipgloss equivalents or centralize"
Impact: Reduces code duplication, improves consistency

Priority

Medium - This is a code quality improvement that will make the codebase more maintainable and consistent. Not urgent, but valuable for reducing technical debt.

AI generated by Discussion Task Miner - Code Quality Improvement Agent

  • expires on Feb 8, 2026, 9:05 AM UTC

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Centralize ANSI escape sequences in console package Centralize ANSI escape sequences in console package Feb 7, 2026
Copilot AI requested a review from pelikhan February 7, 2026 13:31
@pelikhan pelikhan marked this pull request as ready for review February 7, 2026 13:33
Copilot AI review requested due to automatic review settings February 7, 2026 13:33
@pelikhan pelikhan merged commit 24d9291 into main Feb 7, 2026
123 of 125 checks passed
@pelikhan pelikhan deleted the copilot/centralize-ansi-escape-sequences branch February 7, 2026 13:33
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR centralizes terminal ANSI escape sequences within the pkg/console package and updates call sites to use the shared helpers/constants, improving consistency and ensuring ANSI output is gated by TTY detection (stderr).

Changes:

  • Added pkg/console/terminal.go with shared ANSI constants and cursor movement helpers guarded by tty.IsStderrTerminal().
  • Updated existing console/CLI code to use centralized constants/helpers instead of inline escape sequences.
  • Adjusted ClearScreen() behavior to write to stderr for consistency with other console output.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pkg/console/terminal.go Introduces centralized ANSI constants and cursor movement helpers with stderr TTY gating.
pkg/console/terminal_test.go Adds tests around cursor helpers and non-TTY behavior (currently has correctness/robustness issues).
pkg/console/console.go Switches ClearScreen/ClearLine to shared ANSI constants and stderr TTY detection.
pkg/console/spinner.go Replaces inline escape sequences with shared ANSI constants.
pkg/cli/add_interactive_orchestrator.go Replaces inline clear-screen escape sequence with console.ClearScreen().
Comments suppressed due to low confidence (1)

pkg/console/terminal_test.go:114

  • These assertions are ineffective: output is a string, so it can never be nil. If the intent is “does not panic”, use assert.NotPanics around MoveCursorDown, and/or assert the captured output is empty given captureStderr forces stderr to be non-TTY.
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			output := captureStderr(t, func() {
				MoveCursorDown(tt.lines)
			})

			// In non-TTY environments, output should be empty
			// We just ensure no panic occurs
			assert.NotNil(t, output, "MoveCursorDown should not panic")
		})

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to +35
func MoveCursorUp(n int) {
if tty.IsStderrTerminal() {
fmt.Fprintf(os.Stderr, "\033[%dA", n)
}
}

// MoveCursorDown moves cursor down n lines if stderr is a TTY.
// Uses ANSI escape code: \033[nB where n is the number of lines.
func MoveCursorDown(n int) {
if tty.IsStderrTerminal() {
fmt.Fprintf(os.Stderr, "\033[%dB", n)
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

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

MoveCursorUp/MoveCursorDown will emit escape sequences for n==0 and will format negative values into the ANSI sequence (e.g., "\033[-1A"), which is not meaningful and may behave inconsistently across terminals. Consider validating n > 0 and returning early otherwise (or clamping at 0) to make the API safer.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +50
// Save original stderr
oldStderr := os.Stderr

// Create a pipe to capture stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("Failed to create pipe: %v", err)
}

// Replace stderr with the write end of the pipe
os.Stderr = w

// Create a channel to receive the captured output
outputChan := make(chan string, 1)

// Read from the pipe in a goroutine
go func() {
var buf bytes.Buffer
io.Copy(&buf, r)
outputChan <- buf.String()
}()

// Execute the function
fn()

// Close the write end and restore stderr
w.Close()
os.Stderr = oldStderr

// Get the captured output
output := <-outputChan
r.Close()

Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

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

captureStderr mutates global os.Stderr but doesn’t guarantee restoration/pipe closure if fn panics, which can leak the redirected stderr into later tests and can also deadlock the reader goroutine (io.Copy won’t finish until w is closed). Use t.Cleanup/defer to restore os.Stderr and close w/r, and consider recovering panic to ensure cleanup always runs.

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +82
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := captureStderr(t, func() {
MoveCursorUp(tt.lines)
})

// In non-TTY environments, output should be empty
// We just ensure no panic occurs
assert.NotNil(t, output, "MoveCursorUp should not panic")
})
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

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

These assertions are ineffective: output is a string, so it can never be nil. If the intent is “does not panic”, use assert.NotPanics around MoveCursorUp, and/or assert the captured output is empty given captureStderr forces stderr to be non-TTY.

This issue also appears on line 105 of the same file.

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +145
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := captureStderr(t, tt.fn)

// Since tests typically run in non-TTY, verify output is empty
// This ensures we properly respect TTY detection
if os.Getenv("CI") != "" || !isRealTerminal() {
assert.Empty(t, output, "%s should not output ANSI codes in non-TTY", tt.name)
}
})
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

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

This test can become a no-op locally because the assertion is gated on CI env / isRealTerminal(). Since captureStderr always redirects stderr to a pipe (non-TTY), you can assert output is empty unconditionally here to actually validate the TTY guard behavior.

Copilot uses AI. Check for mistakes.
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.

[Code Quality] Centralize ANSI escape sequences in console package

3 participants