11 Commits

22 changed files with 687 additions and 107 deletions

0
.editorconfig Normal file → Executable file
View File

0
.github/FUNDING.yml vendored Normal file → Executable file
View File

58
.github/workflows/manual-homebrew-release.yml vendored Normal file → Executable file
View File

@@ -3,56 +3,10 @@ name: Manual Homebrew Release
on:
workflow_dispatch:
permissions:
contents: read
jobs:
release-homebrew:
name: Trigger Homebrew Formula Update
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get latest release info
id: latest
run: |
tag=$(gh release view --json tagName -q .tagName)
echo "Latest release tag: $tag"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# Get release body and escape for JSON
body=$(gh release view --json body -q .body)
# Use delimiter for multiline output
echo "body<<EOF" >> "$GITHUB_OUTPUT"
echo "$body" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Send dispatch to homebrew-tap
env:
GH_TOKEN: ${{ secrets.REPO_DISPATCH_PAT }}
run: |
tag="${{ steps.latest.outputs.tag }}"
repo="${{ github.event.repository.name }}"
# Use jq to properly escape the body for JSON
body=$(cat <<'BODY_EOF'
${{ steps.latest.outputs.body }}
BODY_EOF
)
data=$(jq -n \
--arg tag "$tag" \
--arg repo "$repo" \
--arg body "$body" \
'{event_type: "trigger-from-release", client_payload: {tag: $tag, repo: $repo, body: $body}}')
echo "Dispatching tag $tag from $repo"
echo "Data: $data"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/chenasraf/homebrew-tap/dispatches \
-d "$data"
echo "Dispatched tag $tag from $repo"
echo "Created job on https://github.com/chenasraf/homebrew-tap/actions"
homebrew:
uses: chenasraf/workflows/.github/workflows/manual-homebrew-release.yml@master
with:
homebrew-tap-repo: chenasraf/homebrew-tap
secrets:
REPO_DISPATCH_PAT: ${{ secrets.REPO_DISPATCH_PAT }}

0
.github/workflows/release.yml vendored Normal file → Executable file
View File

0
.github/workflows/test.yml vendored Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

0
.golangci.yml Normal file → Executable file
View File

0
.prettierrc Normal file → Executable file
View File

22
CHANGELOG.md Normal file → Executable file
View File

@@ -1,5 +1,27 @@
# Changelog
## [1.7.0](https://github.com/chenasraf/watchr/compare/v1.6.0...v1.7.0) (2026-01-26)
### Features
* add keybinding 'c' to stop running command ([9df4fb8](https://github.com/chenasraf/watchr/commit/9df4fb8285a0181d29cfc6034165ce7cb21ab14b))
## [1.6.0](https://github.com/chenasraf/watchr/compare/v1.5.2...v1.6.0) (2026-01-24)
### Features
* add refresh countdown timer ([63b4530](https://github.com/chenasraf/watchr/commit/63b45309b76def1b5be9c11d5228d97bb7ab0a6d))
* add refresh-from-start setting ([f520a8b](https://github.com/chenasraf/watchr/commit/f520a8b4ed665c3187bab88707df9e5efdc779bc))
* reset refresh timer after manual refresh ([10a9208](https://github.com/chenasraf/watchr/commit/10a92082b6c11fcfba2bd835ed2336d1f33d1c04))
* stream new output in-place without resetting existing output ([8aaf514](https://github.com/chenasraf/watchr/commit/8aaf5148ab17b33a72ff3f946c57029c33bdb4d0))
### Bug Fixes
* streaming cursor position ([347ac34](https://github.com/chenasraf/watchr/commit/347ac340942c3ee8d2731432f808ac2b9dd39060))
## [1.5.2](https://github.com/chenasraf/watchr/compare/v1.5.1...v1.5.2) (2026-01-24)

0
LICENSE Normal file → Executable file
View File

0
Makefile Normal file → Executable file
View File

2
README.md Normal file → Executable file
View File

@@ -7,6 +7,8 @@ provides vim-style navigation, filtering, and a preview pane—all without leavi
![Downloads](https://img.shields.io/github/downloads/chenasraf/watchr/total)
![License](https://img.shields.io/github/license/chenasraf/watchr)
![Promo](https://github.com/user-attachments/assets/ec5ab94b-ef91-40d8-a604-9047212a8faf)
---
## 🚀 Features

0
go.mod Normal file → Executable file
View File

0
go.sum Normal file → Executable file
View File

20
internal/config/config.go Normal file → Executable file
View File

@@ -15,14 +15,15 @@ import (
// Config keys
const (
KeyShell = "shell"
KeyPreviewSize = "preview-size"
KeyPreviewPosition = "preview-position"
KeyLineNumbers = "line-numbers"
KeyLineWidth = "line-width"
KeyPrompt = "prompt"
KeyRefresh = "refresh"
KeyInteractive = "interactive"
KeyShell = "shell"
KeyPreviewSize = "preview-size"
KeyPreviewPosition = "preview-position"
KeyLineNumbers = "line-numbers"
KeyLineWidth = "line-width"
KeyPrompt = "prompt"
KeyRefresh = "refresh"
KeyRefreshFromStart = "refresh-from-start"
KeyInteractive = "interactive"
)
// setDefaults sets the default configuration values.
@@ -34,6 +35,7 @@ func setDefaults() {
viper.SetDefault(KeyLineWidth, 6)
viper.SetDefault(KeyPrompt, "watchr> ")
viper.SetDefault(KeyRefresh, "0")
viper.SetDefault(KeyRefreshFromStart, false)
viper.SetDefault(KeyInteractive, false)
}
@@ -79,6 +81,7 @@ func BindFlags(flags *pflag.FlagSet) {
_ = viper.BindPFlag(KeyLineWidth, flags.Lookup("line-width"))
_ = viper.BindPFlag(KeyPrompt, flags.Lookup("prompt"))
_ = viper.BindPFlag(KeyRefresh, flags.Lookup("refresh"))
_ = viper.BindPFlag(KeyRefreshFromStart, flags.Lookup("refresh-from-start"))
_ = viper.BindPFlag(KeyInteractive, flags.Lookup("interactive"))
// line-numbers is inverted (no-line-numbers flag)
@@ -133,6 +136,7 @@ func PrintConfig() {
fmt.Printf(" %-20s %d\n", KeyLineWidth+":", GetInt(KeyLineWidth))
fmt.Printf(" %-20s %q\n", KeyPrompt+":", GetString(KeyPrompt))
fmt.Printf(" %-20s %s\n", KeyRefresh+":", GetString(KeyRefresh))
fmt.Printf(" %-20s %v\n", KeyRefreshFromStart+":", GetBool(KeyRefreshFromStart))
fmt.Printf(" %-20s %v\n", KeyInteractive+":", GetBool(KeyInteractive))
}

105
internal/config/config_test.go Normal file → Executable file
View File

@@ -133,7 +133,9 @@ func TestBindFlags(t *testing.T) {
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
// Parse with custom values
err := flags.Parse([]string{"--shell=bash", "--preview-size=50%", "--line-width=8"})
@@ -239,7 +241,9 @@ preview-size: "60%"
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
// Override shell via flag
err := flags.Parse([]string{"--shell=bash"})
@@ -556,6 +560,107 @@ func TestGetDuration(t *testing.T) {
}
}
func TestRefreshFromStartDefault(t *testing.T) {
_, cleanup := isolateConfig(t)
defer cleanup()
Init()
// Default should be false
if got := GetBool(KeyRefreshFromStart); got != false {
t.Errorf("expected default refresh-from-start false, got %v", got)
}
}
func TestRefreshFromStartFromConfigFile(t *testing.T) {
tmpDir, cleanup := isolateConfig(t)
defer cleanup()
// Create config file with refresh-from-start: true
configPath := filepath.Join(tmpDir, "watchr.yaml")
configContent := `refresh-from-start: true
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
Init()
if got := GetBool(KeyRefreshFromStart); got != true {
t.Errorf("expected refresh-from-start true from config file, got %v", got)
}
}
func TestRefreshFromStartFromFlag(t *testing.T) {
_, cleanup := isolateConfig(t)
defer cleanup()
Init()
// Create flags and parse
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.String("shell", "sh", "")
flags.String("preview-size", "40%", "")
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
// Parse with refresh-from-start=true
err := flags.Parse([]string{"--refresh-from-start=true"})
if err != nil {
t.Fatalf("failed to parse flags: %v", err)
}
BindFlags(flags)
if got := GetBool(KeyRefreshFromStart); got != true {
t.Errorf("expected refresh-from-start true from flag, got %v", got)
}
}
func TestRefreshFromStartFlagOverridesConfig(t *testing.T) {
tmpDir, cleanup := isolateConfig(t)
defer cleanup()
// Create config file with refresh-from-start: true
configPath := filepath.Join(tmpDir, "watchr.yaml")
configContent := `refresh-from-start: true
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
Init()
// Create flags and parse with refresh-from-start=false
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.String("shell", "sh", "")
flags.String("preview-size", "40%", "")
flags.String("preview-position", "bottom", "")
flags.Int("line-width", 6, "")
flags.String("prompt", "watchr> ", "")
flags.String("refresh", "0", "")
flags.Bool("refresh-from-start", false, "")
flags.Bool("no-line-numbers", false, "")
flags.Bool("interactive", false, "")
err := flags.Parse([]string{"--refresh-from-start=false"})
if err != nil {
t.Fatalf("failed to parse flags: %v", err)
}
BindFlags(flags)
// Flag should override config
if got := GetBool(KeyRefreshFromStart); got != false {
t.Errorf("expected refresh-from-start false (flag override), got %v", got)
}
}
func TestRefreshDurationFromConfigFile(t *testing.T) {
tmpDir, cleanup := isolateConfig(t)
defer cleanup()

52
internal/runner/runner.go Normal file → Executable file
View File

@@ -178,11 +178,13 @@ func (r *Runner) Run(ctx context.Context) (Result, error) {
// StreamingResult holds the state of a streaming command
type StreamingResult struct {
Lines *[]Line
ExitCode int
Done bool
Error error
mu sync.RWMutex
Lines *[]Line
ExitCode int
Done bool
Error error
PrevLineCount int // Number of lines from previous run (for trimming)
CurrentLineCount int // Number of lines written by current run
mu sync.RWMutex
}
// GetLines returns a copy of the current lines (thread-safe)
@@ -214,14 +216,27 @@ func (s *StreamingResult) IsDone() bool {
return s.Done
}
// GetCurrentLineCount returns the number of lines written by the current run (thread-safe)
func (s *StreamingResult) GetCurrentLineCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.CurrentLineCount
}
// RunStreaming executes the command and streams output lines in the background.
// Returns a StreamingResult that can be polled for updates.
// The command runs until ctx is cancelled or it completes naturally.
func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult {
// If prevLines is provided, lines are updated in place rather than starting fresh.
func (r *Runner) RunStreaming(ctx context.Context, prevLines []Line) *StreamingResult {
// Copy previous lines to allow in-place updates
lines := make([]Line, len(prevLines))
copy(lines, prevLines)
result := &StreamingResult{
Lines: &[]Line{},
ExitCode: -1,
Done: false,
Lines: &lines,
ExitCode: -1,
Done: false,
PrevLineCount: len(prevLines),
}
go func() {
@@ -268,14 +283,27 @@ func (r *Runner) RunStreaming(ctx context.Context) *StreamingResult {
for scanner.Scan() {
lineNumMu.Lock()
currentLineNum := lineNum
lineIdx := lineNum - 1 // 0-indexed
lineNum++
lineNumMu.Unlock()
result.mu.Lock()
*result.Lines = append(*result.Lines, Line{
newLine := Line{
Number: currentLineNum,
Content: sanitizeLine(scanner.Text()),
})
}
result.mu.Lock()
if lineIdx < len(*result.Lines) {
// Update existing line in place
(*result.Lines)[lineIdx] = newLine
} else {
// Append new line
*result.Lines = append(*result.Lines, newLine)
}
// Track how many lines this run has produced
if currentLineNum > result.CurrentLineCount {
result.CurrentLineCount = currentLineNum
}
result.mu.Unlock()
}
}

144
internal/runner/runner_test.go Normal file → Executable file
View File

@@ -378,3 +378,147 @@ func TestSanitizeLine(t *testing.T) {
})
}
}
func TestRunStreaming(t *testing.T) {
r := NewRunner("sh", "echo 'line1'; echo 'line2'; echo 'line3'")
ctx := context.Background()
result := r.RunStreaming(ctx, nil)
// Wait for completion
for !result.IsDone() {
time.Sleep(10 * time.Millisecond)
}
lines := result.GetLines()
if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d", len(lines))
}
if lines[0].Content != "line1" {
t.Errorf("expected first line 'line1', got %q", lines[0].Content)
}
if result.GetCurrentLineCount() != 3 {
t.Errorf("expected CurrentLineCount 3, got %d", result.GetCurrentLineCount())
}
}
func TestRunStreamingWithPreviousLines(t *testing.T) {
// Previous lines that should be overwritten
prevLines := []Line{
{Number: 1, Content: "old1"},
{Number: 2, Content: "old2"},
{Number: 3, Content: "old3"},
{Number: 4, Content: "old4"},
{Number: 5, Content: "old5"},
}
r := NewRunner("sh", "echo 'new1'; echo 'new2'; echo 'new3'")
ctx := context.Background()
result := r.RunStreaming(ctx, prevLines)
// Verify PrevLineCount is set
if result.PrevLineCount != 5 {
t.Errorf("expected PrevLineCount 5, got %d", result.PrevLineCount)
}
// Wait for completion
for !result.IsDone() {
time.Sleep(10 * time.Millisecond)
}
lines := result.GetLines()
// Should still have 5 lines (3 new + 2 old remaining)
if len(lines) != 5 {
t.Fatalf("expected 5 lines (in-place update), got %d", len(lines))
}
// First 3 lines should be overwritten
if lines[0].Content != "new1" {
t.Errorf("expected line 0 'new1', got %q", lines[0].Content)
}
if lines[1].Content != "new2" {
t.Errorf("expected line 1 'new2', got %q", lines[1].Content)
}
if lines[2].Content != "new3" {
t.Errorf("expected line 2 'new3', got %q", lines[2].Content)
}
// Remaining lines should be old (not touched)
if lines[3].Content != "old4" {
t.Errorf("expected line 3 'old4', got %q", lines[3].Content)
}
if lines[4].Content != "old5" {
t.Errorf("expected line 4 'old5', got %q", lines[4].Content)
}
// CurrentLineCount should be 3 (only new lines written)
if result.GetCurrentLineCount() != 3 {
t.Errorf("expected CurrentLineCount 3, got %d", result.GetCurrentLineCount())
}
}
func TestRunStreamingMoreLinesThanPrevious(t *testing.T) {
// Previous lines (fewer than new output)
prevLines := []Line{
{Number: 1, Content: "old1"},
{Number: 2, Content: "old2"},
}
r := NewRunner("sh", "echo 'new1'; echo 'new2'; echo 'new3'; echo 'new4'")
ctx := context.Background()
result := r.RunStreaming(ctx, prevLines)
// Wait for completion
for !result.IsDone() {
time.Sleep(10 * time.Millisecond)
}
lines := result.GetLines()
// Should have 4 lines (2 overwritten + 2 appended)
if len(lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(lines))
}
// All lines should be new
for i, expected := range []string{"new1", "new2", "new3", "new4"} {
if lines[i].Content != expected {
t.Errorf("expected line %d %q, got %q", i, expected, lines[i].Content)
}
}
if result.GetCurrentLineCount() != 4 {
t.Errorf("expected CurrentLineCount 4, got %d", result.GetCurrentLineCount())
}
}
func TestStreamingResultThreadSafety(t *testing.T) {
r := NewRunner("sh", "for i in $(seq 1 100); do echo line$i; done")
ctx := context.Background()
result := r.RunStreaming(ctx, nil)
// Concurrently read while streaming
done := make(chan bool)
go func() {
for !result.IsDone() {
_ = result.GetLines()
_ = result.LineCount()
_ = result.GetCurrentLineCount()
time.Sleep(5 * time.Millisecond)
}
done <- true
}()
<-done
// Should complete without race conditions
if result.LineCount() != 100 {
t.Errorf("expected 100 lines, got %d", result.LineCount())
}
}

182
internal/ui/ui.go Normal file → Executable file
View File

@@ -34,33 +34,37 @@ type Config struct {
LineNumWidth int
Prompt string
RefreshInterval time.Duration
RefreshFromStart bool // If true, refresh timer starts when command starts; if false, when command ends (default)
Interactive bool
}
// model represents the application state
type model struct {
config Config
lines []runner.Line
filtered []int // indices into lines that match filter
cursor int // cursor position in filtered list
offset int // scroll offset for visible window
filter string
filterMode bool
showPreview bool
showHelp bool // help overlay visible
width int
height int
runner *runner.Runner
ctx context.Context
cancel context.CancelFunc
loading bool
streaming bool // true while command is running (streaming output)
streamResult *runner.StreamingResult // current streaming result
lastLineCount int // track line count for updates
spinnerFrame int // current spinner animation frame
errorMsg string
statusMsg string // temporary status message (e.g., "Yanked!")
exitCode int // last command exit code
config Config
lines []runner.Line
filtered []int // indices into lines that match filter
cursor int // cursor position in filtered list
offset int // scroll offset for visible window
filter string
filterMode bool
showPreview bool
showHelp bool // help overlay visible
width int
height int
runner *runner.Runner
ctx context.Context
cancel context.CancelFunc
loading bool
streaming bool // true while command is running (streaming output)
streamResult *runner.StreamingResult // current streaming result
lastLineCount int // track line count for updates
userScrolled bool // true if user manually scrolled during streaming
refreshGeneration int // incremented on manual refresh to reset timer
refreshStartTime time.Time // when the refresh timer was started
spinnerFrame int // current spinner animation frame
errorMsg string
statusMsg string // temporary status message (e.g., "Yanked!")
exitCode int // last command exit code
}
// messages
@@ -69,11 +73,16 @@ type resultMsg struct {
exitCode int
}
type errMsg struct{ err error }
type tickMsg time.Time
type tickMsg struct {
generation int
}
type clearStatusMsg struct{}
type spinnerTickMsg time.Time
type streamTickMsg time.Time // periodic check for streaming updates
type startStreamMsg struct{} // trigger to start streaming
type streamTickMsg time.Time // periodic check for streaming updates
type startStreamMsg struct{} // trigger to start streaming
type countdownTickMsg struct { // periodic update for refresh countdown display
generation int
}
// Spinner frames for the loading animation
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
@@ -149,6 +158,13 @@ func (m model) streamTickCmd() tea.Cmd {
})
}
func (m model) countdownTickCmd() tea.Cmd {
gen := m.refreshGeneration
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return countdownTickMsg{generation: gen}
})
}
func (m *model) startStreaming() tea.Cmd {
// Cancel any existing context and create a new one
if m.cancel != nil {
@@ -156,14 +172,27 @@ func (m *model) startStreaming() tea.Cmd {
}
m.ctx, m.cancel = context.WithCancel(context.Background())
m.streamResult = m.runner.RunStreaming(m.ctx)
// Pass previous lines for in-place updates
m.streamResult = m.runner.RunStreaming(m.ctx, m.lines)
m.streaming = true
m.loading = true
m.lastLineCount = 0
m.lastLineCount = len(m.lines)
m.exitCode = -1
m.errorMsg = ""
m.userScrolled = false
return m.streamTickCmd()
cmds := []tea.Cmd{m.streamTickCmd()}
// Start refresh timer from command start if configured
if m.config.RefreshFromStart && m.config.RefreshInterval > 0 {
m.refreshStartTime = time.Now()
cmds = append(cmds, m.tickCmd())
if m.config.RefreshInterval > time.Second {
cmds = append(cmds, m.countdownTickCmd())
}
}
return tea.Batch(cmds...)
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -201,6 +230,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lines = newLines
m.lastLineCount = newCount
m.updateFiltered()
// Auto-scroll to bottom if user hasn't manually scrolled
if !m.userScrolled {
visible := m.visibleLines()
if visible > 0 {
m.cursor = max(len(m.filtered)-1, 0)
m.offset = max(len(m.filtered)-visible, 0)
}
}
}
// Check if command completed
@@ -212,9 +250,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.errorMsg = m.streamResult.Error.Error()
}
// If auto-refresh is enabled, schedule the next run
if m.config.RefreshInterval > 0 {
return m, m.tickCmd()
// Trim excess lines from previous run
currentCount := m.streamResult.GetCurrentLineCount()
if currentCount < len(m.lines) {
m.lines = m.lines[:currentCount]
m.updateFiltered()
}
// If auto-refresh is enabled and timer starts from end, schedule the next run
if m.config.RefreshInterval > 0 && !m.config.RefreshFromStart {
m.refreshStartTime = time.Now()
cmds := []tea.Cmd{m.tickCmd()}
// Start countdown display updates if interval > 1s
if m.config.RefreshInterval > time.Second {
cmds = append(cmds, m.countdownTickCmd())
}
return m, tea.Batch(cmds...)
}
return m, nil
}
@@ -223,6 +274,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.streamTickCmd()
case tickMsg:
// Ignore ticks from before a manual refresh
if msg.generation != m.refreshGeneration {
return m, nil
}
if m.config.RefreshInterval > 0 && !m.streaming {
// Restart streaming for refresh
cmd := m.startStreaming()
@@ -246,14 +301,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.spinnerTickCmd()
}
return m, nil
case countdownTickMsg:
// Ignore ticks from before a manual refresh
if msg.generation != m.refreshGeneration {
return m, nil
}
// Continue ticking if waiting for auto-refresh
if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() {
elapsed := time.Since(m.refreshStartTime)
if elapsed < m.config.RefreshInterval {
return m, m.countdownTickCmd()
}
}
return m, nil
}
return m, nil
}
func (m model) tickCmd() tea.Cmd {
gen := m.refreshGeneration
return tea.Tick(m.config.RefreshInterval, func(t time.Time) tea.Msg {
return tickMsg(t)
return tickMsg{generation: gen}
})
}
@@ -309,32 +379,50 @@ func (m *model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Quit
case "j", "down", "ctrl+n":
m.userScrolled = true
m.moveCursor(1)
case "k", "up", "ctrl+p":
m.userScrolled = true
m.moveCursor(-1)
case "g", "home":
m.userScrolled = true
m.cursor = 0
m.offset = 0
case "G", "end":
m.userScrolled = false // Resume following output
if len(m.filtered) > 0 {
m.cursor = len(m.filtered) - 1
m.adjustOffset()
}
case "ctrl+d":
m.userScrolled = true
m.moveCursor(m.visibleLines() / 2)
case "ctrl+u":
m.userScrolled = true
m.moveCursor(-m.visibleLines() / 2)
case "pgdown", "ctrl+f":
m.userScrolled = true
m.moveCursor(m.visibleLines())
case "pgup", "ctrl+b":
m.userScrolled = true
m.moveCursor(-m.visibleLines())
case "p":
m.showPreview = !m.showPreview
m.adjustOffset() // Keep selected line visible after preview toggle
case "r", "ctrl+r":
// Restart streaming
// Restart streaming and reset auto-refresh timer
m.refreshGeneration++
cmd := m.startStreaming()
return m, tea.Batch(cmd, m.spinnerTickCmd())
case "c":
// Stop the running command if one is running
if m.streaming {
m.cancel()
m.statusMsg = "Command stopped"
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return clearStatusMsg{}
})
}
case "/":
m.filterMode = true
m.filter = ""
@@ -494,7 +582,16 @@ func (m *model) updateFiltered() {
if m.cursor < 0 {
m.cursor = 0
}
m.offset = 0
// Clamp offset to valid bounds instead of resetting to 0
// This preserves scroll position during streaming updates
visible := m.visibleLines()
if visible > 0 {
maxOffset := max(len(m.filtered)-visible, 0)
if m.offset > maxOffset {
m.offset = maxOffset
}
}
}
// renderHelpOverlay creates the help box content (without positioning)
@@ -526,6 +623,7 @@ func (m model) renderHelpOverlay() (box string, boxWidth, boxHeight int) {
{"Esc", "Exit filter / clear"},
{"", ""},
{"r / Ctrl+r", "Reload command"},
{"c", "Stop running command"},
{"y", "Copy line to clipboard"},
{"q / Esc", "Quit"},
{"?", "Toggle this help"},
@@ -846,6 +944,22 @@ func (m model) renderMainView() string {
commandLine = prefix + failStyle.Render(fmt.Sprintf("✗ [%d] %s", m.exitCode, m.config.Command))
}
// Add refresh countdown on the right if auto-refresh is enabled and > 1s
if m.config.RefreshInterval > time.Second && !m.streaming && !m.refreshStartTime.IsZero() {
elapsed := time.Since(m.refreshStartTime)
remaining := m.config.RefreshInterval - elapsed
if remaining > 0 {
countdownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) // dim gray
countdown := countdownStyle.Render(fmt.Sprintf("(%ds)", int(remaining.Seconds())+1))
cmdWidth := lipgloss.Width(commandLine)
countdownWidth := lipgloss.Width(countdown)
gap := innerWidth - cmdWidth - countdownWidth
if gap > 0 {
commandLine += strings.Repeat(" ", gap) + countdown
}
}
}
// Build prompt line (will go at bottom)
var promptLine string
switch {

203
internal/ui/ui_test.go Normal file → Executable file
View File

@@ -1,9 +1,11 @@
package ui
import (
"context"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/chenasraf/watchr/internal/runner"
)
@@ -284,3 +286,204 @@ func TestVisibleLines(t *testing.T) {
t.Errorf("expected %d visible lines with absolute preview size, got %d", expected, visible)
}
}
func TestUpdateFilteredPreservesOffset(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
m.height = 20 // Enough for visibleLines to return > 0
// Add many test lines
for i := 1; i <= 100; i++ {
m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"})
}
// Set initial state with offset
m.filter = ""
m.updateFiltered()
m.offset = 50
m.cursor = 55
// Simulate streaming update - add more lines without changing filter
m.lines = append(m.lines, runner.Line{Number: 101, Content: "new line"})
m.updateFiltered()
// Offset should be preserved (or clamped if necessary)
if m.offset < 50 {
t.Errorf("expected offset to be preserved (>= 50), got %d", m.offset)
}
// Cursor should be preserved
if m.cursor != 55 {
t.Errorf("expected cursor to be preserved at 55, got %d", m.cursor)
}
}
func TestUpdateFilteredClampsOffsetWhenNeeded(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
m.height = 20
// Add test lines
for i := 1; i <= 100; i++ {
m.lines = append(m.lines, runner.Line{Number: i, Content: "line content"})
}
m.filter = ""
m.updateFiltered()
m.offset = 90
m.cursor = 95
// Now filter to fewer lines
m.filter = "xyz" // No matches
m.updateFiltered()
// Offset should be clamped to valid range
if m.offset != 0 {
t.Errorf("expected offset to be clamped to 0, got %d", m.offset)
}
// Cursor should be clamped
if m.cursor != 0 {
t.Errorf("expected cursor to be clamped to 0, got %d", m.cursor)
}
}
func TestConfigRefreshFromStart(t *testing.T) {
// Test with RefreshFromStart false (default)
cfg := Config{
Command: "echo test",
Shell: "sh",
RefreshInterval: 5 * time.Second,
RefreshFromStart: false,
}
if cfg.RefreshFromStart {
t.Error("expected RefreshFromStart to be false by default")
}
// Test with RefreshFromStart true
cfg.RefreshFromStart = true
if !cfg.RefreshFromStart {
t.Error("expected RefreshFromStart to be true after setting")
}
}
func TestModelUserScrolled(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
// Initially should be false
if m.userScrolled {
t.Error("expected userScrolled to be false initially")
}
// After setting, should be true
m.userScrolled = true
if !m.userScrolled {
t.Error("expected userScrolled to be true after setting")
}
}
func TestModelRefreshGeneration(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
m := initialModel(cfg)
// Initially should be 0
if m.refreshGeneration != 0 {
t.Errorf("expected refreshGeneration to be 0 initially, got %d", m.refreshGeneration)
}
// After incrementing
m.refreshGeneration++
if m.refreshGeneration != 1 {
t.Errorf("expected refreshGeneration to be 1 after increment, got %d", m.refreshGeneration)
}
}
func TestStopCommandKeybinding(t *testing.T) {
cfg := Config{
Command: "echo test",
Shell: "sh",
}
t.Run("stops running command when streaming", func(t *testing.T) {
m := initialModel(cfg)
// Set up a cancellable context to track if cancel was called
ctx, cancel := context.WithCancel(context.Background())
m.ctx = ctx
m.cancel = cancel
m.streaming = true
m.statusMsg = ""
// Simulate pressing 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}
result, cmd := m.handleKeyPress(keyMsg)
newModel := result.(*model)
// Should set status message
if newModel.statusMsg != "Command stopped" {
t.Errorf("expected statusMsg 'Command stopped', got %q", newModel.statusMsg)
}
// Should return a command (the tick for clearing status)
if cmd == nil {
t.Error("expected a command to be returned for status message timeout")
}
// Context should be cancelled
select {
case <-ctx.Done():
// Good, context was cancelled
default:
t.Error("expected context to be cancelled")
}
})
t.Run("does nothing when not streaming", func(t *testing.T) {
m := initialModel(cfg)
ctx, cancel := context.WithCancel(context.Background())
m.ctx = ctx
m.cancel = cancel
m.streaming = false
m.statusMsg = ""
// Simulate pressing 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}
result, cmd := m.handleKeyPress(keyMsg)
newModel := result.(*model)
// Should not set status message
if newModel.statusMsg != "" {
t.Errorf("expected empty statusMsg, got %q", newModel.statusMsg)
}
// Should not return a command
if cmd != nil {
t.Error("expected no command to be returned when not streaming")
}
// Context should NOT be cancelled
select {
case <-ctx.Done():
t.Error("expected context to NOT be cancelled when not streaming")
default:
// Good, context is still active
}
})
}

4
main.go Normal file → Executable file
View File

@@ -35,6 +35,7 @@ func main() {
flag.StringP("prompt", "p", "watchr> ", "Prompt string")
flag.StringP("shell", "s", "sh", "Shell to use for executing commands")
flag.StringP("refresh", "r", "0", "Auto-refresh interval (e.g., 1, 1.5, 500ms, 2s, 5m, 1h; default unit: seconds, 0 = disabled)")
flag.Bool("refresh-from-start", false, "Start refresh timer when command starts (default: when command ends)")
flag.BoolP("interactive", "i", false, "Run shell in interactive mode (sources ~/.bashrc, ~/.zshrc, etc.)")
printUsage := func(w *os.File) {
@@ -46,6 +47,7 @@ func main() {
flag.CommandLine.SetOutput(os.Stderr)
_, _ = fmt.Fprintf(w, "\nKeybindings:\n")
_, _ = fmt.Fprintf(w, " r, Ctrl-r Reload (re-run command)\n")
_, _ = fmt.Fprintf(w, " c Stop running command\n")
_, _ = fmt.Fprintf(w, " q, Esc Quit\n")
_, _ = fmt.Fprintf(w, " j, k Move down/up\n")
_, _ = fmt.Fprintf(w, " g Go to first line\n")
@@ -109,6 +111,7 @@ func main() {
lineNumWidth := config.GetInt(config.KeyLineWidth)
prompt := config.GetString(config.KeyPrompt)
refreshInterval := config.GetDuration(config.KeyRefresh)
refreshFromStart := config.GetBool(config.KeyRefreshFromStart)
showLineNums := config.ShowLineNumbers()
interactive := config.GetBool(config.KeyInteractive)
@@ -131,6 +134,7 @@ func main() {
LineNumWidth: lineNumWidth,
Prompt: prompt,
RefreshInterval: refreshInterval,
RefreshFromStart: refreshFromStart,
Interactive: interactive,
}

2
version.txt Normal file → Executable file
View File

@@ -1 +1 @@
1.5.2
1.7.0