add follow mode for streaming input

This commit is contained in:
Daisuke Maki 2026-06-04 07:55:46 +09:00 committed by Daisuke Maki
parent 5f6ec1b21c
commit a66446d2e2
10 changed files with 364 additions and 9 deletions

View file

@ -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 |

View file

@ -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) {

View file

@ -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)

View file

@ -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.

194
follow_test.go Normal file
View file

@ -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")
}

View file

@ -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)
}
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()

View file

@ -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.

14
page.go
View file

@ -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)
}

14
peco.go
View file

@ -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

View file

@ -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 {