• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 21743054702

06 Feb 2026 07:54AM UTC coverage: 80.111% (-0.8%) from 80.861%
21743054702

push

github

umputun
refactor: address code review findings and improve package structure

- Remove dead code: IsPlanDraft, IsTerminalSignal, NewErrorEvent, NewWarnEvent, Phase aliases
- Fix stale comments, extract magic numbers, remove redundant conditions
- Improve session manager: add error logging, convert standalone functions to methods
- Deduplicate shared code: maxScannerBuffer constant, progress file parsing, review pipeline
- Extract shared types to pkg/status (signals, Phase, Section) from processor/signals
- Merge pkg/render into pkg/input
- Move Logger interface to consumer-side in pkg/web, decoupling web from processor

195 of 205 new or added lines in 13 files covered. (95.12%)

1 existing line in 1 file now uncovered.

4463 of 5571 relevant lines covered (80.11%)

157.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.32
/pkg/input/input.go
1
// Package input provides terminal input collection for interactive plan creation.
2
package input
3

4
import (
5
        "bufio"
6
        "context"
7
        "errors"
8
        "fmt"
9
        "io"
10
        "os"
11
        "os/exec"
12
        "strconv"
13
        "strings"
14

15
        "github.com/charmbracelet/glamour"
16
)
17

18
// ReadLineResult holds the result of reading a line
19
type readLineResult struct {
20
        line string
21
        err  error
22
}
23

24
// ReadLineWithContext reads a line from reader with context cancellation support.
25
// returns the line (including newline), error, or context error if canceled.
26
// this allows Ctrl+C (SIGINT) to interrupt blocking stdin reads.
27
func ReadLineWithContext(ctx context.Context, reader *bufio.Reader) (string, error) {
39✔
28
        resultCh := make(chan readLineResult, 1)
39✔
29

39✔
30
        if err := ctx.Err(); err != nil {
41✔
31
                return "", fmt.Errorf("read line: %w", err)
2✔
32
        }
2✔
33

34
        go func() {
74✔
35
                line, err := reader.ReadString('\n')
37✔
36
                resultCh <- readLineResult{line: line, err: err}
37✔
37
        }()
37✔
38

39
        select {
37✔
40
        case <-ctx.Done():
×
41
                return "", fmt.Errorf("read line: %w", ctx.Err())
×
42
        case result := <-resultCh:
37✔
43
                return result.line, result.err
37✔
44
        }
45
}
46

47
//go:generate moq -out mocks/collector.go -pkg mocks -skip-ensure -fmt goimports . Collector
48

49
// Collector provides interactive input collection for plan creation.
50
type Collector interface {
51
        // AskQuestion presents a question with options and returns the selected answer.
52
        // Returns the selected option text or error if selection fails.
53
        AskQuestion(ctx context.Context, question string, options []string) (string, error)
54

55
        // AskDraftReview presents a plan draft for review with Accept/Revise/Reject options.
56
        // Returns the selected action ("accept", "revise", or "reject") and feedback text (empty for accept/reject).
57
        AskDraftReview(ctx context.Context, question string, planContent string) (action string, feedback string, err error)
58
}
59

60
// TerminalCollector implements Collector using fzf (if available) or numbered selection fallback.
61
type TerminalCollector struct {
62
        stdin   io.Reader // for testing, nil uses os.Stdin
63
        stdout  io.Writer // for testing, nil uses os.Stdout
64
        noColor bool      // if true, skip glamour rendering
65
}
66

67
// NewTerminalCollector creates a new TerminalCollector with specified options.
68
func NewTerminalCollector(noColor bool) *TerminalCollector {
4✔
69
        return &TerminalCollector{noColor: noColor}
4✔
70
}
4✔
71

72
func (c *TerminalCollector) getStdin() io.Reader {
32✔
73
        if c.stdin != nil {
64✔
74
                return c.stdin
32✔
75
        }
32✔
76
        return os.Stdin
×
77
}
78

79
func (c *TerminalCollector) getStdout() io.Writer {
32✔
80
        if c.stdout != nil {
64✔
81
                return c.stdout
32✔
82
        }
32✔
83
        return os.Stdout
×
84
}
85

86
// AskQuestion presents options using fzf if available, otherwise falls back to numbered selection.
87
func (c *TerminalCollector) AskQuestion(ctx context.Context, question string, options []string) (string, error) {
2✔
88
        if len(options) == 0 {
4✔
89
                return "", errors.New("no options provided")
2✔
90
        }
2✔
91

92
        // try fzf first
93
        if hasFzf() {
×
94
                return c.selectWithFzf(ctx, question, options)
×
95
        }
×
96

97
        // fallback to numbered selection
98
        return c.selectWithNumbers(ctx, question, options)
×
99
}
100

101
// hasFzf checks if fzf is available in PATH.
102
func hasFzf() bool {
×
103
        _, err := exec.LookPath("fzf")
×
104
        return err == nil
×
105
}
×
106

107
// selectWithFzf uses fzf for interactive selection.
108
func (c *TerminalCollector) selectWithFzf(ctx context.Context, question string, options []string) (string, error) {
×
109
        input := strings.Join(options, "\n")
×
110

×
111
        cmd := exec.CommandContext(ctx, "fzf", "--prompt", question+": ", "--height", "10", "--layout=reverse") //nolint:gosec // fzf is a trusted external tool, question is user-provided prompt text
×
112
        cmd.Stdin = strings.NewReader(input)
×
113
        cmd.Stderr = os.Stderr
×
114

×
115
        output, err := cmd.Output()
×
116
        if err != nil {
×
117
                // fzf returns exit code 130 when user presses Escape
×
118
                var exitErr *exec.ExitError
×
119
                if errors.As(err, &exitErr) && exitErr.ExitCode() == 130 {
×
120
                        return "", errors.New("selection canceled")
×
121
                }
×
122
                return "", fmt.Errorf("fzf selection failed: %w", err)
×
123
        }
124

125
        selected := strings.TrimSpace(string(output))
×
126
        if selected == "" {
×
127
                return "", errors.New("no selection made")
×
128
        }
×
129

130
        return selected, nil
×
131
}
132

133
// selectWithNumbers presents numbered options for selection via stdin.
134
func (c *TerminalCollector) selectWithNumbers(ctx context.Context, question string, options []string) (string, error) {
22✔
135
        stdout := c.getStdout()
22✔
136
        stdin := c.getStdin()
22✔
137

22✔
138
        // print question and options
22✔
139
        _, _ = fmt.Fprintln(stdout)
22✔
140
        _, _ = fmt.Fprintln(stdout, question)
22✔
141
        for i, opt := range options {
79✔
142
                _, _ = fmt.Fprintf(stdout, "  %d) %s\n", i+1, opt)
57✔
143
        }
57✔
144
        _, _ = fmt.Fprintf(stdout, "Enter number (1-%d): ", len(options))
22✔
145

22✔
146
        // read selection
22✔
147
        reader := bufio.NewReader(stdin)
22✔
148
        line, err := ReadLineWithContext(ctx, reader)
22✔
149
        if err != nil {
25✔
150
                return "", fmt.Errorf("read input: %w", err)
3✔
151
        }
3✔
152

153
        // parse selection
154
        line = strings.TrimSpace(line)
19✔
155
        num, err := strconv.Atoi(line)
19✔
156
        if err != nil {
21✔
157
                return "", fmt.Errorf("invalid number: %s", line)
2✔
158
        }
2✔
159

160
        if num < 1 || num > len(options) {
21✔
161
                return "", fmt.Errorf("selection out of range: %d (must be 1-%d)", num, len(options))
4✔
162
        }
4✔
163

164
        return options[num-1], nil
13✔
165
}
166

167
// AskYesNo prompts with [y/N] and returns true for yes.
168
// defaults to no on EOF, empty input, context cancellation, or any read error.
169
func AskYesNo(ctx context.Context, prompt string, stdin io.Reader, stdout io.Writer) bool {
13✔
170
        fmt.Fprintf(stdout, "%s [y/N]: ", prompt)
13✔
171
        reader := bufio.NewReader(stdin)
13✔
172
        line, err := ReadLineWithContext(ctx, reader)
13✔
173
        if err != nil {
15✔
174
                // EOF (Ctrl+D), context canceled (Ctrl+C), or read error
2✔
175
                // print newline so subsequent output doesn't appear on the same line
2✔
176
                fmt.Fprintln(stdout)
2✔
177
                return false
2✔
178
        }
2✔
179
        answer := strings.TrimSpace(strings.ToLower(line))
11✔
180
        return answer == "y" || answer == "yes"
11✔
181
}
182

183
// draft review action constants
184
const (
185
        ActionAccept = "accept"
186
        ActionRevise = "revise"
187
        ActionReject = "reject"
188
)
189

190
// AskDraftReview presents a plan draft for review with Accept/Revise/Reject options.
191
// Shows the rendered plan content, then prompts for action selection.
192
// If Revise is selected, prompts for feedback text.
193
// Returns action ("accept", "revise", "reject") and feedback (empty for accept/reject).
194
func (c *TerminalCollector) AskDraftReview(ctx context.Context, question, planContent string) (string, string, error) {
10✔
195
        stdout := c.getStdout()
10✔
196
        stdin := c.getStdin()
10✔
197

10✔
198
        // render and display the plan
10✔
199
        rendered, err := c.renderMarkdown(planContent)
10✔
200
        if err != nil {
10✔
201
                return "", "", fmt.Errorf("render plan: %w", err)
×
202
        }
×
203

204
        _, _ = fmt.Fprintln(stdout)
10✔
205
        _, _ = fmt.Fprintln(stdout, "━━━ Plan Draft ━━━")
10✔
206
        _, _ = fmt.Fprintln(stdout, rendered)
10✔
207
        _, _ = fmt.Fprintln(stdout, "━━━━━━━━━━━━━━━━━━")
10✔
208
        _, _ = fmt.Fprintln(stdout)
10✔
209

10✔
210
        // present action options
10✔
211
        options := []string{"Accept", "Revise", "Reject"}
10✔
212
        action, err := c.selectWithNumbers(ctx, question, options)
10✔
213
        if err != nil {
13✔
214
                return "", "", fmt.Errorf("select action: %w", err)
3✔
215
        }
3✔
216

217
        actionLower := strings.ToLower(action)
7✔
218

7✔
219
        // if revise, prompt for feedback
7✔
220
        if actionLower == ActionRevise {
11✔
221
                _, _ = fmt.Fprintln(stdout)
4✔
222
                _, _ = fmt.Fprint(stdout, "Enter revision feedback: ")
4✔
223

4✔
224
                reader := bufio.NewReader(stdin)
4✔
225
                feedback, readErr := ReadLineWithContext(ctx, reader)
4✔
226
                if readErr != nil {
5✔
227
                        return "", "", fmt.Errorf("read feedback: %w", readErr)
1✔
228
                }
1✔
229
                feedback = strings.TrimSpace(feedback)
3✔
230
                if feedback == "" {
5✔
231
                        return "", "", errors.New("revision feedback cannot be empty")
2✔
232
                }
2✔
233
                return ActionRevise, feedback, nil
1✔
234
        }
235

236
        return actionLower, "", nil
3✔
237
}
238

239
// renderMarkdown renders markdown content for terminal display.
240
// if noColor is true, returns the content unchanged.
241
func (c *TerminalCollector) renderMarkdown(content string) (string, error) {
16✔
242
        if c.noColor {
27✔
243
                return content, nil
11✔
244
        }
11✔
245
        renderer, err := glamour.NewTermRenderer(
5✔
246
                glamour.WithAutoStyle(),
5✔
247
                glamour.WithWordWrap(80),
5✔
248
        )
5✔
249
        if err != nil {
5✔
NEW
250
                return "", fmt.Errorf("create renderer: %w", err)
×
NEW
251
        }
×
252
        result, err := renderer.Render(content)
5✔
253
        if err != nil {
5✔
NEW
254
                return "", fmt.Errorf("render markdown: %w", err)
×
NEW
255
        }
×
256
        return result, nil
5✔
257
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc