From a66446d2e2aea2b0d0293377180729e21f70a8fd Mon Sep 17 00:00:00 2001 From: Daisuke Maki <49281+lestrrat@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:55:46 +0900 Subject: [PATCH] add follow mode for streaming input --- README.md | 21 +++++ action.go | 23 ++++++ buffer.go | 16 +++- config/config.go | 5 ++ follow_test.go | 194 +++++++++++++++++++++++++++++++++++++++++++++++ layout.go | 66 ++++++++++++++-- options.go | 1 + page.go | 14 ++++ peco.go | 14 ++++ state.go | 19 +++++ 10 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 follow_test.go diff --git a/README.md b/README.md index a84f78b..0baf24f 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,26 @@ Without `--height`, peco uses the full terminal screen (default behavior, unchan **Note:** In inline mode, peco sets the environment variable `TCELL_ALTSCREEN=disable` to prevent tcell from using the alternate screen buffer, and restores the original value on exit. If peco is killed abnormally (e.g. `SIGKILL`), you may need to unset this variable manually: `unset TCELL_ALTSCREEN`. +### -f, --follow + +When specified, peco follows streaming input, automatically scrolling to keep the newest lines visible at the bottom of the list, just like `tail -f`. This is useful for live log streams: + +``` +journalctl -f -n 1000 | peco --follow --layout top-down-query-bottom +``` + +Moving the cursor manually (e.g. with the arrow keys) turns follow mode off so you can scroll back through the history. The `ToggleFollow` action turns it back on; bind it to a key in your configuration file, for example: + +```json +{ + "Keymap": { + "C-f": "peco.ToggleFollow" + } +} +``` + +Follow mode can also be enabled by default with the `Follow` configuration variable. The `--follow` command line option takes precedence over the configuration file. + # Configuration File peco by default consults a few locations for the config files. @@ -737,6 +757,7 @@ Some keys just... don't map correctly / too easily for various reasons. Here, we | peco.SelectVisible | Selects the all visible line, and save it | | peco.ToggleQuery | Toggle list between filtered by query and not filtered | | peco.ToggleRangeMode | Start selecting by range, or append selecting range to selections | +| peco.ToggleFollow | Toggle follow mode (auto-scroll to the newest lines, like tail -f) | | peco.ToggleSelectMode | (DEPRECATED) Alias to ToggleRangeMode | | peco.ToggleSelection | Selects the current line, and saves it | | peco.ToggleSelectionAndSelectNext | Selects the current line, saves it, and proceeds to the next line | diff --git a/action.go b/action.go index 3452499..78119b2 100644 --- a/action.go +++ b/action.go @@ -172,6 +172,8 @@ func init() { ActionFunc(doFreezeResults).Register("FreezeResults") ActionFunc(doUnfreezeResults).Register("UnfreezeResults") + ActionFunc(doToggleFollow).Register("ToggleFollow") + ActionFunc(doZoomIn).Register("ZoomIn") ActionFunc(doZoomOut).Register("ZoomOut") @@ -966,6 +968,27 @@ func doUnfreezeResults(ctx context.Context, state *Peco, _ Event) { state.Hub().SendDrawPrompt(ctx) } +// doToggleFollow turns follow mode on or off. In follow mode peco auto-scrolls +// to keep the newest input lines visible, like "tail -f". Enabling it redraws +// so the viewport jumps to the tail immediately. +func doToggleFollow(ctx context.Context, state *Peco, _ Event) { + if pdebug.Enabled { + g := pdebug.Marker("doToggleFollow") + defer g.End() + } + + follow := state.Follow() + enabled := !follow.Enabled() + follow.Set(enabled) + + if enabled { + state.Hub().SendStatusMsg(ctx, "Follow mode on", 0) + state.Hub().SendDraw(ctx, &hub.DrawOptions{DisableCache: true}) + return + } + state.Hub().SendStatusMsg(ctx, "Follow mode off", 0) +} + // doZoomIn expands the view to show context lines around matched lines by building // a ContextBuffer from the current filter results and the original source. func doZoomIn(ctx context.Context, state *Peco, _ Event) { diff --git a/buffer.go b/buffer.go index 2d72159..9f18904 100644 --- a/buffer.go +++ b/buffer.go @@ -52,13 +52,23 @@ type ContextLine struct { // NewFilteredBuffer creates a FilteredBuffer containing one page of lines from // the source buffer, computing the maximum column width for horizontal scrolling. func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer { + return newFilteredBufferRange(src, perPage*(page-1), perPage) +} + +// newFilteredBufferRange creates a FilteredBuffer holding up to count lines +// from the source buffer starting at the given index. This is the shared core +// behind both page-aligned cropping (NewFilteredBuffer) and the sliding-window +// crop used by follow mode (WindowCrop), where start is not page-aligned. +func newFilteredBufferRange(src Buffer, start, count int) *FilteredBuffer { fb := FilteredBuffer{ src: src, } - start := perPage * (page - 1) + if start < 0 { + start = 0 + } - // if for whatever reason we wanted a page that goes over the + // if for whatever reason we wanted a range that goes over the // capacity of the original buffer, we don't need to do any more // calculations. bail out if start > src.Size() { @@ -66,7 +76,7 @@ func NewFilteredBuffer(src Buffer, page, perPage int) *FilteredBuffer { } // Copy over the selections that are applicable to this filtered buffer. - end := min(start+perPage, src.Size()) + end := min(start+count, src.Size()) selection := make([]int, 0, end-start) lines := src.linesInRange(start, end) diff --git a/config/config.go b/config/config.go index c44a564..7db37d3 100644 --- a/config/config.go +++ b/config/config.go @@ -96,6 +96,11 @@ type Config struct { // Height specifies the display height in lines or percentage (e.g. "10", "50%"). // When set, peco renders inline without using the alternate screen buffer. Height string `json:"Height" yaml:"Height"` + + // Follow enables follow mode at startup: peco auto-scrolls to keep the + // newest input lines visible, like "tail -f". The --follow CLI flag + // overrides this value. + Follow bool `json:"Follow" yaml:"Follow"` } // SingleKeyJumpConfig holds configuration for single key jump mode. diff --git a/follow_test.go b/follow_test.go new file mode 100644 index 0000000..464e866 --- /dev/null +++ b/follow_test.go @@ -0,0 +1,194 @@ +package peco + +import ( + "fmt" + "strings" + "testing" + + "github.com/peco/peco/filter" + "github.com/peco/peco/hub" + "github.com/peco/peco/line" + "github.com/stretchr/testify/require" +) + +// makeFollowBuffer builds a MemoryBuffer with n lines labelled "lineN". +func makeFollowBuffer(n int) *MemoryBuffer { + mb := NewMemoryBuffer(0) + for i := range n { + mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line%d", i), false, false)) + } + return mb +} + +// readScreenRow returns the text rendered on row y of the simulation screen, +// trailing blanks trimmed. +func readScreenRow(t *testing.T, s *SimScreen, y int) string { + t.Helper() + cells, w, _ := s.screen.GetContents() + var b strings.Builder + for x := range w { + c := cells[y*w+x] + if len(c.Runes) == 0 || c.Runes[0] == 0 { + b.WriteRune(' ') + continue + } + b.WriteRune(c.Runes[0]) + } + return strings.TrimRight(b.String(), " ") +} + +func newFollowState(t *testing.T, buf Buffer) (*Peco, *SimScreen) { + t.Helper() + screen := NewDummyScreen() + state := New() + state.screen = screen + state.Filters().Add(filter.NewIgnoreCase()) + state.currentLineBuffer = buf + return state, screen +} + +func TestWindowCrop(t *testing.T) { + buf := makeFollowBuffer(100) + + t.Run("tail window", func(t *testing.T) { + fb := WindowCrop{offset: 90, perPage: 10}.Crop(buf) + require.Equal(t, 10, fb.Size()) + first, err := fb.LineAt(0) + require.NoError(t, err) + require.Equal(t, "line90", first.DisplayString()) + last, err := fb.LineAt(9) + require.NoError(t, err) + require.Equal(t, "line99", last.DisplayString()) + }) + + t.Run("partial window smaller than perPage", func(t *testing.T) { + small := makeFollowBuffer(5) + fb := WindowCrop{offset: 0, perPage: 10}.Crop(small) + require.Equal(t, 5, fb.Size()) + }) + + t.Run("negative offset clamps to zero", func(t *testing.T) { + fb := WindowCrop{offset: -5, perPage: 3}.Crop(buf) + require.Equal(t, 3, fb.Size()) + first, err := fb.LineAt(0) + require.NoError(t, err) + require.Equal(t, "line0", first.DisplayString()) + }) + + t.Run("offset past end yields empty", func(t *testing.T) { + fb := WindowCrop{offset: 200, perPage: 10}.Crop(buf) + require.Equal(t, 0, fb.Size()) + }) +} + +// TestGHIssue820_FollowKeepsNewestVisible verifies that with follow mode on, +// DrawScreen pins the viewport to the tail of the buffer so the newest line +// is rendered on the last visible row (like tail -f), regardless of page +// alignment. +func TestGHIssue820_FollowKeepsNewestVisible(t *testing.T) { + buf := makeFollowBuffer(50) + state, screen := newFollowState(t, buf) + state.Follow().Set(true) + + // top-down-query-bottom is the layout from the issue: list anchored to + // the top (row 0), query at the bottom. + layout, err := TopDownQueryBottomLayout(state) + require.NoError(t, err) + + layout.DrawScreen(state, nil) + + loc := state.Location() + perPage := layout.linesPerPage() + + require.Equal(t, 49, loc.LineNumber(), "cursor should be pinned to the newest line") + require.Equal(t, 50, loc.Total()) + require.Equal(t, max(50-perPage, 0), loc.Offset(), + "offset should be a sliding window over the tail, not page-aligned") + + // The list area is anchored at row 0; the newest line sits on the last + // visible row. + lastRow := perPage - 1 + require.Equal(t, "line49", readScreenRow(t, screen, lastRow), + "newest line should be on the bottom row of the list area") +} + +// TestFollowDefaultShowsHead verifies that without follow mode the viewport +// stays at the head of the buffer (unchanged behavior). +func TestFollowDefaultShowsHead(t *testing.T) { + buf := makeFollowBuffer(50) + state, screen := newFollowState(t, buf) + require.False(t, state.IsFollowing()) + + layout, err := TopDownQueryBottomLayout(state) + require.NoError(t, err) + + layout.DrawScreen(state, nil) + + loc := state.Location() + require.Equal(t, 0, loc.LineNumber()) + require.Equal(t, 0, loc.Offset()) + require.Equal(t, "line0", readScreenRow(t, screen, 0), + "oldest line should be on the top row when not following") +} + +// TestFollowDisabledByManualScroll verifies that any manual vertical +// navigation turns follow mode off. +func TestFollowDisabledByManualScroll(t *testing.T) { + buf := makeFollowBuffer(50) + state, _ := newFollowState(t, buf) + state.Follow().Set(true) + + layout, err := TopDownQueryBottomLayout(state) + require.NoError(t, err) + // Pin to the tail first. + layout.DrawScreen(state, nil) + require.True(t, state.IsFollowing()) + + moved := layout.MovePage(state, hub.ToLineAbove) + require.True(t, moved) + require.False(t, state.IsFollowing(), + "manual vertical scroll should disable follow mode") +} + +// TestToggleFollowAction verifies the ToggleFollow action flips follow state. +func TestToggleFollowAction(t *testing.T) { + state := New() + state.hub = nullHub{} + require.False(t, state.IsFollowing()) + + doToggleFollow(t.Context(), state, Event{}) + require.True(t, state.IsFollowing(), "follow should be on after first toggle") + + doToggleFollow(t.Context(), state, Event{}) + require.False(t, state.IsFollowing(), "follow should be off after second toggle") +} + +// TestFollowConfigAndFlag verifies that --follow and the Follow config field +// both enable follow mode, with the CLI flag taking precedence. +func TestFollowConfigAndFlag(t *testing.T) { + t.Run("CLI flag", func(t *testing.T) { + p := newPeco() + require.NoError(t, p.ApplyConfig(CLIOptions{OptFollow: true})) + require.True(t, p.IsFollowing()) + }) + + t.Run("config field", func(t *testing.T) { + p := newPeco() + p.config.Follow = true + require.NoError(t, p.ApplyConfig(CLIOptions{})) + require.True(t, p.IsFollowing()) + }) + + t.Run("default off", func(t *testing.T) { + p := newPeco() + require.NoError(t, p.ApplyConfig(CLIOptions{})) + require.False(t, p.IsFollowing()) + }) +} + +// TestToggleFollowRegistered verifies the action is wired into the registry so +// it can be bound from a keymap. +func TestToggleFollowRegistered(t *testing.T) { + _, ok := nameToActions["peco.ToggleFollow"] + require.True(t, ok, "peco.ToggleFollow should be registered") +} diff --git a/layout.go b/layout.go index 9201186..bb01836 100644 --- a/layout.go +++ b/layout.go @@ -328,6 +328,9 @@ func (u UserPrompt) Draw(state *Peco) { loc := state.Location() pmsg := fmt.Sprintf("%s [%d (%d/%d)]", state.Filters().Current().String(), loc.Total(), loc.Page(), loc.MaxPage()) + if state.IsFollowing() { + pmsg = "FOLLOW " + pmsg + } u.screen.Print(PrintArgs{ X: width - runewidth.StringWidth(pmsg), Y: location, @@ -581,16 +584,26 @@ func (l *ListArea) Draw(state *Peco, parent Layout, perPage int, options *hub.Dr loc := state.Location() linebuf := state.CurrentLineBuffer() + following := state.IsFollowing() - if options != nil && options.RunningQuery { + // While following, the viewport is pinned to the tail of the buffer + // (see calculateFollowPage), so the running-query page adjustment must + // not move it. + if !following && options != nil && options.RunningQuery { adjustPageForRunningQuery(loc, linebuf, parent, state) } - pf := loc.PageCrop() - if pdebug.Enabled { - pdebug.Printf("Cropping linebuf which contains %d lines at page %d (%d entries per page)", linebuf.Size(), pf.currentPage, pf.perPage) + var buf *FilteredBuffer + if following { + // Sliding window over the tail so the newest line is on the last row. + buf = WindowCrop{offset: loc.Offset(), perPage: loc.PerPage()}.Crop(linebuf) + } else { + pf := loc.PageCrop() + if pdebug.Enabled { + pdebug.Printf("Cropping linebuf which contains %d lines at page %d (%d entries per page)", linebuf.Size(), pf.currentPage, pf.perPage) + } + buf = pf.Crop(linebuf) } - buf := pf.Crop(linebuf) bufsiz := buf.Size() // This protects us from losing the selected line in case our selected @@ -910,6 +923,39 @@ func (l *BasicLayout) CalculatePage(state *Peco, perPage int) error { return nil } +// calculateFollowPage pins the viewport to the tail of the buffer for follow +// mode. Unlike CalculatePage, the offset is a sliding window (Size-perPage) +// rather than a page boundary, so the newest line always lands on the last +// visible row. The cursor is pinned to the newest line. +func (l *BasicLayout) calculateFollowPage(state *Peco, perPage int) error { + if pdebug.Enabled { + g := pdebug.Marker("BasicLayout.calculateFollowPage %d", perPage) + defer g.End() + } + buf := state.CurrentLineBuffer() + loc := state.Location() + + total := buf.Size() + loc.SetPerPage(perPage) + loc.SetTotal(total) + + if total == 0 { + loc.SetOffset(0) + loc.SetLineNumber(0) + loc.SetPage(1) + loc.SetMaxPage(1) + // wait for targets + return errors.New("no targets or query. nothing to do") + } + + loc.SetOffset(max(total-perPage, 0)) + loc.SetLineNumber(total - 1) + loc.SetMaxPage((total + perPage - 1) / perPage) + loc.SetPage(loc.MaxPage()) + + return nil +} + // DrawPrompt draws the prompt to the terminal func (l *BasicLayout) DrawPrompt(state *Peco) { l.prompt.Draw(state) @@ -924,7 +970,11 @@ func (l *BasicLayout) DrawScreen(state *Peco, options *hub.DrawOptions) { perPage := l.linesPerPage() - if err := l.CalculatePage(state, perPage); err != nil { + calculate := l.CalculatePage + if state.IsFollowing() { + calculate = l.calculateFollowPage + } + if err := calculate(state, perPage); err != nil { return } @@ -1097,6 +1147,10 @@ func updateRangeSelection(state *Peco, buf Buffer, loc *Location, lineBefore, lc // verticalScroll moves the cursor position vertically func verticalScroll(state *Peco, l *BasicLayout, p hub.PagingRequest) bool { + // Manual vertical navigation cancels follow mode so the user can scroll + // back through history. Re-enable it with the ToggleFollow action. + state.Follow().Set(false) + loc := state.Location() lineBefore := loc.LineNumber() diff --git a/options.go b/options.go index f1de19d..0280b29 100644 --- a/options.go +++ b/options.go @@ -33,6 +33,7 @@ type CLIOptions struct { OptPrintQuery bool `long:"print-query" description:"print out the current query as first line of output"` OptColor config.ColorMode `long:"color" description:"color mode: 'auto' (default, parse ANSI codes) or 'none' (disable)" default:"auto"` OptHeight string `long:"height" description:"display height in lines or percentage (e.g. '10', '50%')"` + OptFollow bool `long:"follow" short:"f" description:"follow streaming input, auto-scrolling to keep the newest lines\nvisible (like 'tail -f'). Manual cursor movement turns this off"` } // parse parses command-line arguments and validates the resulting options. diff --git a/page.go b/page.go index 1ad4670..3c08508 100644 --- a/page.go +++ b/page.go @@ -120,3 +120,17 @@ func (l *Location) PageCrop() PageCrop { func (pf PageCrop) Crop(in Buffer) *FilteredBuffer { return NewFilteredBuffer(in, pf.currentPage, pf.perPage) } + +// WindowCrop crops a fixed-size window of lines starting at an explicit +// offset rather than a page boundary. Follow mode uses it to show the tail +// of the buffer (the newest lines) regardless of page alignment. +type WindowCrop struct { + offset int + perPage int +} + +// Crop returns a new Buffer containing up to perPage lines starting at the +// window's offset. +func (wc WindowCrop) Crop(in Buffer) *FilteredBuffer { + return newFilteredBufferRange(in, wc.offset, wc.perPage) +} diff --git a/peco.go b/peco.go index 27c81f7..b73392c 100644 --- a/peco.go +++ b/peco.go @@ -96,6 +96,8 @@ type Peco struct { frozen FrozenState + follow FollowState + zoom ZoomState // cancelFunc is called for Exit() @@ -291,6 +293,16 @@ func (p *Peco) Frozen() *FrozenState { return &p.frozen } +// Follow returns the follow-mode state. +func (p *Peco) Follow() *FollowState { + return &p.follow +} + +// IsFollowing reports whether follow mode is currently active. +func (p *Peco) IsFollowing() bool { + return p.follow.Enabled() +} + func (p *Peco) Zoom() *ZoomState { return &p.zoom } @@ -794,6 +806,8 @@ func (p *Peco) ApplyConfig(opts CLIOptions) error { p.selectOneAndExit = opts.OptSelect1 p.exitZeroAndExit = opts.OptExitZero p.selectAllAndExit = opts.OptSelectAll + // Follow mode: CLI flag overrides config. + p.follow.Set(opts.OptFollow || p.config.Follow) p.printQuery = opts.OptPrintQuery p.initialQuery = opts.OptQuery p.initialFilter = opts.OptInitialFilter diff --git a/state.go b/state.go index 9120205..c495689 100644 --- a/state.go +++ b/state.go @@ -2,6 +2,7 @@ package peco import ( "sync" + "sync/atomic" "time" ) @@ -75,6 +76,24 @@ func (z *ZoomState) Clear() { z.lineNo = 0 } +// FollowState tracks whether follow mode is active. In follow mode peco +// auto-scrolls to keep the newest input lines visible, like "tail -f". +// It is toggled at runtime: manual cursor movement turns it off, and the +// ToggleFollow action turns it back on. +type FollowState struct { + enabled atomic.Bool +} + +// Enabled reports whether follow mode is currently active. +func (f *FollowState) Enabled() bool { + return f.enabled.Load() +} + +// Set turns follow mode on or off. +func (f *FollowState) Set(enabled bool) { + f.enabled.Store(enabled) +} + // FrozenState holds a snapshot of filter results when the user // "freezes" the current results to filter on top of them. type FrozenState struct {