From d30b1ea1a87e7067a6a44a015dbe1a984154e3e5 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 26 Oct 2026 15:17:40 +0000 Subject: [PATCH 1/5] docs(openspec): propose the wrap-engine-logs change --- .../changes/wrap-engine-logs/.openspec.yaml | 2 + openspec/changes/wrap-engine-logs/design.md | 84 ++++++++++++ openspec/changes/wrap-engine-logs/proposal.md | 50 ++++++++ .../specs/fleet-client/spec.md | 80 ++++++++++++ .../specs/local-serving/spec.md | 120 ++++++++++++++++++ openspec/changes/wrap-engine-logs/tasks.md | 40 ++++++ 6 files changed, 376 insertions(+) create mode 100644 openspec/changes/wrap-engine-logs/.openspec.yaml create mode 100644 openspec/changes/wrap-engine-logs/design.md create mode 100644 openspec/changes/wrap-engine-logs/proposal.md create mode 100644 openspec/changes/wrap-engine-logs/specs/fleet-client/spec.md create mode 100644 openspec/changes/wrap-engine-logs/specs/local-serving/spec.md create mode 100644 openspec/changes/wrap-engine-logs/tasks.md diff --git a/openspec/changes/wrap-engine-logs/.openspec.yaml b/openspec/changes/wrap-engine-logs/.openspec.yaml new file mode 100644 index 00000000..d58ba023 --- /dev/null +++ b/openspec/changes/wrap-engine-logs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-26 diff --git a/openspec/changes/wrap-engine-logs/design.md b/openspec/changes/wrap-engine-logs/design.md new file mode 100644 index 00000000..8a6beb36 --- /dev/null +++ b/openspec/changes/wrap-engine-logs/design.md @@ -0,0 +1,84 @@ +# Design + +## Summary + +Both surfaces already share the three-section frame and the clipper +(`dashClip`); wrapping follows the same pattern — one shared width-aware +wrapper, a per-view wrap flag, and row-based slicing of the pane at draw +time. Nothing about the tailing, budgets, or polling changes: wrapping is a +rendering concern, applied between "lines we retain" and "rows we draw". + +## Decisions + +### The toggle key is `w`, in both views + +Neither view answers to `w` today (serve: arrows/pgup/pgdown/`f`/`q`; +detail: `esc`/`f`/`s`/`k`/`a`/`x`), it is mnemonic, and one key for one +behaviour across surfaces matches how `f follow` already works. The footer +gains `w wrap` in both views, in the same `key meaning` shape as its +neighbours, and the header's log-state area gains the wrap state beside the +follow state — `log: following wrap: on` — so the current mode is visible +without pressing keys, the same transparency the paused state gives. + +### Wrapping is hard character wrapping, not word wrapping + +Log lines are data, not prose: a JSON blob or a llama.cpp prompt line may +carry no spaces to break on at all, and breaking on spaces would leave +ragged short rows and mis-align columns a reader may be scanning. A hard +cut at exactly the pane width, terminal-style, loses nothing and keeps every +continuation row full. Continuation rows start at the left margin with no +marker or indent — the terminal's own `tail` behaviour is what operators +already read. + +### A shared `dashWrapLines` beside `dashClip` + +`cmd/spinloop/dashboard_render.go` gains the wrapper (it already owns the +clipping that keeps a panel the shape of the frame). It returns a line's +rows for a given width: one row when it fits or the width is not exceeded, +`ceil(width/paneWidth)` rows otherwise. Rows are cut at exact *display* +columns — wide characters count two — via `ansi.StringWidth` and repeated +`ansi.CutWc(line, 0, n)` for the head plus a rune-walking remainder, rather +than `CutWc(line, left, right)` with a non-zero left: in the pinned +`charmbracelet/x/ansi` the WcWidth path mishandles `left`, and log content +is plain text anyway (the daemon's capture strips escapes before it reaches +the file), so only display width, not ANSI state, has to survive the cut. +The fast path — the line fits in the pane, which is every line on a normal +terminal — costs one width call. + +### Retention stays line-based; only the pane counts rows + +`logContent` budgets (`serveViewLogBudget`, `detailLogCapacity`) stay +"lines retained" and the poll/trim code is untouched. At draw time a new +shared step expands the window's lines to rows — each line via `dashWrapLines` +when wrapping is on, via `dashClip` (one row) when off — and takes the last +`avail` **rows** for the pane. With wrapping off this is byte-identical to +today (one row per line), so no existing behaviour shifts. + +### Scrolling stays line-based, so wrapped lines move whole + +The serve view's `logBehind` counts log lines, not rows: the window's last +visible line is `lines[len-1-logBehind]`, the retained prefix up to it is +expanded to rows, and the pane shows the last `avail` of those rows. Up or +down therefore moves whole wrapped lines in and out of the pane, which is +what "scroll one line" must mean here; the topmost row shown may be a +continuation fragment, exactly as on a terminal running `tail`. `clampBehind` +keeps its current formula (`lines − paneRows`) — unchanged semantics when +wrapped off, and a conservative, pane-full bound when wrapped on. The detail +view has no scroll; its tail-slice simply counts rows now. + +### Metrics lines keep clipping + +The issue is about engine logs. Metrics and bar lines are fixed-shape panel +content — `dashClip`'s own comment explains why they must not wrap — and the +grid is untouched. + +## Risks + +- **Per-frame wrap cost**: only the pane's worth is expanded — the window's + lines, capped by `avail` rows once sliced from the bottom — every poll + tick (3s), so even a pane of 200-column wrapped rows is trivial next to + the terminal repaint itself. +- **Tests that pin the clip**: `TestDashDetailViewTruncatesOverflowingLogToAvailableRows` + and any serve-view test feeding over-width lines now assert wrapping by + default; those tests get updated to say so, plus new tests for the `w` + toggle and header state on both views. diff --git a/openspec/changes/wrap-engine-logs/proposal.md b/openspec/changes/wrap-engine-logs/proposal.md new file mode 100644 index 00000000..6fce5feb --- /dev/null +++ b/openspec/changes/wrap-engine-logs/proposal.md @@ -0,0 +1,50 @@ +# Proposal + +## Why + +The serve view and the dashboard's node detail view draw every log line with +a hard clip at the pane width, so an engine line longer than the terminal — +a stack trace, a request dump, a llama.cpp prompt log — silently loses +everything past the cut ([issue #267](https://github.com/spinloop-ai/spinloop/issues/267)). +For logs, the tail of a long line is usually the interesting part, and +there is no way to reach it from the view. + +## What Changes + +- Log lines in the serve view's log section and the dashboard detail view's + log section wrap onto continuation rows instead of clipping, by default. +- A new `w` key toggles wrapping on and off in both views; the wrap state is + shown beside the log's follow state in the header, and `w wrap` joins the + footer key help in both views. +- Scrolling and tailing keep working at line granularity: the pane shows the + window's log lines expanded to their wrapped rows, most recent rows at the + bottom. Metrics sections are unaffected and stay clipped. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `local-serving` — "The serve view's log" gains wrapped-by-default log + rows and the `w` wrap toggle (with scenarios); "The serve view's keys and + exit" names `w` among the keys the view offers. +- `fleet-client` — "The detail view shows the node's metrics and log" gains + the same wrapped-by-default behaviour and `w` toggle for its log section. + +## Impact + +- `cmd/spinloop/serve_view.go` — wrap state, `w` key, footer help, row-based + window slicing. +- `cmd/spinloop/dashboard_detail.go` — same for the detail view; `w` key in + `updateDetailKey`. +- `cmd/spinloop/dashboard_model.go` — `detailKeys` footer gains `w wrap`. +- `cmd/spinloop/dashboard_render.go` — a shared width-aware line-wrapper + beside `dashClip`. +- View tests in `cmd/spinloop/serve_view_test.go` and + `cmd/spinloop/fleet_dashboard_test.go`. + +No wire formats, daemon API, or log-file behaviour change — this is entirely +a rendering concern. diff --git a/openspec/changes/wrap-engine-logs/specs/fleet-client/spec.md b/openspec/changes/wrap-engine-logs/specs/fleet-client/spec.md new file mode 100644 index 00000000..69f1faec --- /dev/null +++ b/openspec/changes/wrap-engine-logs/specs/fleet-client/spec.md @@ -0,0 +1,80 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: The detail view shows the node's metrics and log + +The detail view SHALL be laid out as three stacked sections: the node's +metrics, the node's engine log, and a line naming the keys the view answers +to. The metrics section SHALL show the same facts, in the same wording, that +the node's tile and `fleet metrics` show for it, without the tile's clipping — +a fact that does not fit the tile SHALL be shown in full here. The metrics +section SHALL update on the node's normal refresh cadence, exactly as the +tile does. + +The log section SHALL show the node's engine log, tailing and following it +the same way `spinloop fleet logs -f ` follows one node's log, so new +output appears while the view is open without the operator asking for it. A +node whose engine has not run yet, or that cannot supply its log, SHALL show +the same explanation the `fleet logs` command gives for it, rather than an +empty pane. + +A log line longer than the view's width SHALL wrap onto continuation rows at +the left margin, so the whole line is readable within the view; wrapping +SHALL be on by default. The operator SHALL be able to turn wrapping off and +back on from the keyboard with the same key the serve view answers to; while +wrapping is off a longer line SHALL be cut at the view's width as before. The +view SHALL show whether wrapping is on or off. The pane's rows are then its +lines expanded to their wrapped rows, the newest rows at the pane's bottom. + +The operator SHALL be able to pause and resume the log's follow from the +keyboard, independently of the rest of the view: while paused, the log +section SHALL stop picking up new output, and the view SHALL show whether the +log is following or paused. Nothing written while paused is lost: resuming +SHALL fetch and show whatever the engine wrote in the meantime, the same +backlog a poll would have picked up had it never paused. Pausing SHALL NOT +affect the metrics section's own refresh, or any node's start, stop or abort. + +#### Scenario: Metrics match the tile + +- **WHEN** the detail view is open on a node with a running engine +- **THEN** the metrics section shows the same state, serving facts, resource + usage and counters the node's tile shows, in full rather than clipped + +#### Scenario: The log pane follows new output + +- **WHEN** the node's engine writes to its log while the detail view is open +- **THEN** the new lines appear in the log section without the operator + pressing any key + +#### Scenario: A line longer than the view wraps + +- **WHEN** the log shows a line wider than the view's width +- **THEN** the line is drawn across as many rows as it needs, every row + starting at the left margin, with no part of the line lost + +#### Scenario: Turning wrapping off and on + +- **WHEN** the operator turns wrapping off +- **THEN** lines longer than the view are cut at its width again, and the + view shows that wrapping is off +- **AND** turning it back on wraps those lines again + +#### Scenario: A node with no log yet + +- **WHEN** the detail view is open on a node whose engine has never run +- **THEN** the log section shows the same explanation `fleet logs` gives for + that node, not an empty pane + +#### Scenario: Pausing the log + +- **WHEN** the operator pauses the log's follow +- **THEN** the log section stops picking up new output and the view shows + that the log is paused +- **AND** the metrics section keeps refreshing on its own cadence + +#### Scenario: Resuming the log + +- **WHEN** the operator resumes a paused log +- **THEN** whatever the engine wrote while paused appears in the log section, + and new output continues to appear as it is written diff --git a/openspec/changes/wrap-engine-logs/specs/local-serving/spec.md b/openspec/changes/wrap-engine-logs/specs/local-serving/spec.md new file mode 100644 index 00000000..ca92e206 --- /dev/null +++ b/openspec/changes/wrap-engine-logs/specs/local-serving/spec.md @@ -0,0 +1,120 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: The serve view's log + +The view's log section SHALL show the engine's log, tailing and following it +the same way the dashboard's detail view follows its node's log: new output +appears while the view is open without the operator asking for it. An engine +that has written nothing yet SHALL show a waiting note, not an empty pane. + +A log line longer than the pane's width SHALL wrap onto continuation rows at +the pane's left margin, so the whole line is readable within the pane; +wrapping SHALL be on by default. The operator SHALL be able to turn wrapping +off and back on from the keyboard; while wrapping is off a longer line SHALL +be cut at the pane's width as before. The view SHALL show whether wrapping is +on or off. When wrapping is on, the pane's rows are its window's lines +expanded to their wrapped rows, the newest rows at the pane's bottom. + +The up and down arrow keys SHALL scroll the log pane: up moves the visible +window one line towards the oldest retained line, down one line towards the +newest; a press at either end SHALL leave the window where it is. Page up and +page down SHALL move the window by the pane's height. While the window is on +the newest line, new output SHALL appear in the pane as it is written; while +the window is scrolled away from it, new output SHALL be retained and the pane +SHALL stay where the operator put it until they scroll back to the newest +line, where it sticks again. + +The operator SHALL be able to pause and resume the log's follow from the +keyboard, independently of the rest of the view: while paused, the pane SHALL +stop picking up new output, and the view SHALL show whether the log is +following or paused. Nothing written while paused is lost: resuming SHALL +fetch and show whatever the engine wrote in the meantime, and pausing SHALL +not affect the metrics section's own refresh. + +#### Scenario: The log pane follows new output + +- **WHEN** the engine writes to its log while the view is open and the window + is on the newest line +- **THEN** the new lines appear in the log section without the operator + pressing any key + +#### Scenario: A line longer than the pane wraps + +- **WHEN** the engine writes a line wider than the log pane +- **THEN** the line is drawn across as many rows as it needs, every row + starting at the pane's left margin, with no part of the line lost + +#### Scenario: Turning wrapping off and on + +- **WHEN** the operator turns wrapping off +- **THEN** lines longer than the pane are cut at its width again, and the + view shows that wrapping is off +- **AND** turning it back on wraps those lines again + +#### Scenario: Scrolling a wrapped line + +- **WHEN** the operator scrolls the pane by one line +- **THEN** the whole wrapped line — all of its rows — moves out of or into + the pane as one line + +#### Scenario: Scrolling the log + +- **WHEN** the operator presses the up arrow +- **THEN** the window moves one line towards the older output, and the down + arrow moves it back, line for line, until it is on the newest line again + and sticks to it + +#### Scenario: Paging the log + +- **WHEN** the operator presses page up +- **THEN** the window moves by the pane's height towards the older output, + clamped at the oldest retained line + +#### Scenario: An engine that has written nothing yet + +- **WHEN** the view is open and the engine has written no log output +- **THEN** the log section shows a waiting note, not an empty pane + +#### Scenario: Pausing the log + +- **WHEN** the operator pauses the log's follow +- **THEN** the pane stops picking up new output and the view shows that the + log is paused, while the metrics section keeps refreshing on its own cadence + +#### Scenario: Resuming the log + +- **WHEN** the operator resumes a paused log +- **THEN** whatever the engine wrote while paused appears in the pane, and + new output continues to appear as it is written + +### Requirement: The serve view's keys and exit + +The view SHALL name its keys on screen and offer only the ones that would do +something in it: the up and down arrows and page up and page down scroll the +log, `f` pauses and resumes the log's follow, `w` turns log wrapping on and +off, and `q` or Ctrl+C leaves. The view SHALL NOT offer start, stop, keep or +abort keys: the engine is serve's own — starting is serve's job, and stopping +it is what leaving does. + +`q` and Ctrl+C SHALL stop the engine — gracefully, escalating as a stop does +elsewhere — and exit serve. When the engine exits on its own, whatever the +cause, the view SHALL close and serve SHALL exit with the engine's exit +status, exactly as a foreground serve does today. + +#### Scenario: The key help names only live keys + +- **WHEN** the view draws its key help line +- **THEN** it names the scroll, follow, wrap and quit keys, and nothing the + view cannot do + +#### Scenario: Quitting stops the engine + +- **WHEN** the operator presses q or Ctrl+C +- **THEN** the engine is stopped and serve exits + +#### Scenario: The engine's own exit closes the view + +- **WHEN** the engine process exits while the view is open +- **THEN** the view closes and serve exits with the engine's exit status diff --git a/openspec/changes/wrap-engine-logs/tasks.md b/openspec/changes/wrap-engine-logs/tasks.md new file mode 100644 index 00000000..b7ee5ad3 --- /dev/null +++ b/openspec/changes/wrap-engine-logs/tasks.md @@ -0,0 +1,40 @@ +# Tasks + +## 1. Shared wrapper + +- [ ] 1.1 Add `dashWrapLines(line string, width int) []string` beside + `dashClip` in `cmd/spinloop/dashboard_render.go`: hard wrap at exact + display columns (wide runes count two), one row when the line fits, + plain-text fast path; document why it exists beside the clipper's + "cut, not a wrap" comment. +- [ ] 1.2 Unit-test the wrapper: fits-as-one-row, exact multiple, remainder + row, wide characters, empty line, width < 1 guard. + +## 2. Serve view + +- [ ] 2.1 Add a `logWrap bool` to `serveView`, defaulting on in + `runServeView`; read `w` in `updateKey` to toggle it. +- [ ] 2.2 In `View`, expand the window's lines to rows — wrapped rows when + on, `dashClip` single rows when off — and take the last `avail` rows; + show `wrap: on/off` beside the log state in the header. +- [ ] 2.3 Add `w wrap` to `serveViewKeys` footer help. +- [ ] 2.4 Tests: long line wraps across rows and is drawn in full; `w` + toggles to clipping and back; header shows the state; scrolling moves + whole wrapped lines; existing clip-shaped tests updated to the wrapped + default. + +## 3. Dashboard detail view + +- [ ] 3.1 Add a `detailLogWrap bool` to `dashModel` (set in `openDetail`); + read `w` in `updateDetailKey`. +- [ ] 3.2 In `detailView`, expand log lines to rows the same shared way, + slice to the pane's rows, and show the wrap state in the header for + every node, standing ones included. +- [ ] 3.3 Add `w wrap` to `detailKeys`. +- [ ] 3.4 Tests: wrapping default, `w` toggle, header state, long line in + full; update `TestDashDetailViewTruncatesOverflowingLogToAvailableRows` + for the wrapped default. + +## 4. Gate + +- [ ] 4.1 `gofmt`, `go vet`, `go test ./...` green with coverage ≥ 80%. From c988d20d8117abd54d8f45253861eb232d65778c Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 26 Oct 2026 15:40:14 +0000 Subject: [PATCH 2/5] feat: wrap engine logs in the serve and detail views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log lines wider than the pane were cut, silently losing the tail of long engine output (issue #267). Both views now wrap by default — hard at the display column, terminal-style — with a shared dashLogRows expander counting pane rows while retention and scrolling stay line-based; w toggles wrapping off and back on and the header names the state. --- cmd/spinloop/dashboard_detail.go | 36 ++++--- cmd/spinloop/dashboard_keep_test.go | 4 +- cmd/spinloop/dashboard_model.go | 3 +- cmd/spinloop/dashboard_render.go | 43 +++++++- cmd/spinloop/dashboard_render_test.go | 64 ++++++++++++ cmd/spinloop/fleet_dashboard_test.go | 100 ++++++++++++++++-- cmd/spinloop/serve_view.go | 41 +++++--- cmd/spinloop/serve_view_test.go | 113 +++++++++++++++++++++ openspec/changes/wrap-engine-logs/tasks.md | 22 ++-- 9 files changed, 374 insertions(+), 52 deletions(-) create mode 100644 cmd/spinloop/dashboard_render_test.go diff --git a/cmd/spinloop/dashboard_detail.go b/cmd/spinloop/dashboard_detail.go index 692dcc95..10b3f5cc 100644 --- a/cmd/spinloop/dashboard_detail.go +++ b/cmd/spinloop/dashboard_detail.go @@ -52,6 +52,7 @@ func (m *dashModel) openDetail() tea.Cmd { m.detailLogGen++ m.detailLogBusy = false m.detailLogFollow = true + m.detailLogClip = false m.detailLogOffset = daemon.TailLog m.detailLogContent = "" m.detailLogNote = "" @@ -69,11 +70,11 @@ func (m *dashModel) openDetail() tea.Cmd { // updateDetailKey answers the keys the detail view reads: the same // start/stop/abort the grid applies to the node under the cursor — which is // the node in view, since the cursor never moves while the view is open — -// escape to close it, and f to pause or resume the log tail. Quit lives on -// the grid only: leaving the dashboard from inside the detail view means -// escaping back to it first, the same as any other nested screen. Navigation -// and the manual refresh are the grid's own keys and are not read here -// either. +// escape to close it, f to pause or resume the log tail, and w to turn log +// wrapping on and off. Quit lives on the grid only: leaving the dashboard +// from inside the detail view means escaping back to it first, the same as +// any other nested screen. Navigation and the manual refresh are the grid's +// own keys and are not read here either. func (m *dashModel) updateDetailKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "esc": @@ -83,6 +84,8 @@ func (m *dashModel) updateDetailKey(msg tea.KeyMsg) tea.Cmd { // standing node) for the life of the view; flipping the flag is all // a resume needs — the next tick picks it straight back up. m.detailLogFollow = !m.detailLogFollow + case "w": + m.detailLogClip = !m.detailLogClip case "s": if len(m.entries) > 0 && m.actions[m.cursor].verb == "" { return m.beginAction("start") @@ -210,7 +213,8 @@ func detailLogLines(content, note string) []string { // detailView draws the full-screen frame: header, the node's metrics // unclipped to terminal width, a divider, the tailed log filling the -// remaining rows, a divider, and the footer naming the view's keys. +// remaining rows — wrapped, by default, the way a terminal wraps its own +// output — a divider, and the footer naming the view's keys. func (m dashModel) detailView() string { w := m.effWidth() e := m.entries[m.cursor] @@ -226,6 +230,13 @@ func (m dashModel) detailView() string { } detail += " log: " + logState } + // Wrapping is a rendering fact, not a polling one: it holds the pane + // together for a standing node's note as much as for a live tail. + wrapState := "on" + if m.detailLogClip { + wrapState = "off" + } + detail += " wrap: " + wrapState header := dashTitleBar("fleet dashboard · node: "+e.name, detail, w) divider := strings.Repeat("─", w) @@ -234,19 +245,20 @@ func (m dashModel) detailView() string { content = append(content, "") } logLines := detailLogLines(m.detailLogContent, m.detailLogNote) - if len(logLines) > avail { - logLines = logLines[len(logLines)-avail:] + // The pane counts rows, not lines: a wrapped line contributes as many + // rows as it needs, and the tail's newest rows sit at the bottom. + logRows := dashLogRows(logLines, w, !m.detailLogClip) + if len(logRows) > avail { + logRows = logRows[len(logRows)-avail:] } - parts := make([]string, 0, len(content)+len(logLines)+4) + parts := make([]string, 0, len(content)+len(logRows)+4) parts = append(parts, header, divider) for _, line := range content { parts = append(parts, dashClip(line, w)) } parts = append(parts, divider) - for _, line := range logLines { - parts = append(parts, dashClip(line, w)) - } + parts = append(parts, logRows...) parts = append(parts, divider) parts = append(parts, m.footerLine(w, m.detailKeys())) return strings.Join(parts, "\n") diff --git a/cmd/spinloop/dashboard_keep_test.go b/cmd/spinloop/dashboard_keep_test.go index 0f784dfe..ed8a8e71 100644 --- a/cmd/spinloop/dashboard_keep_test.go +++ b/cmd/spinloop/dashboard_keep_test.go @@ -375,7 +375,7 @@ func TestDashKeepHintOnlyWhereItDrivesSomething(t *testing.T) { if got, want := m.gridKeys(), "↑↓←→ move s start k keep g format r refresh q quit"; got != want { t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want) } - if got, want := m.detailKeys(), "esc back s start k keep f follow"; got != want { + if got, want := m.detailKeys(), "esc back s start k keep f follow w wrap"; got != want { t.Errorf("detail hint:\ngot: %q\nwant: %q", got, want) } }) @@ -386,7 +386,7 @@ func TestDashKeepHintOnlyWhereItDrivesSomething(t *testing.T) { if got, want := m.gridKeys(), "↑↓←→ move k keep x stop g format r refresh q quit"; got != want { t.Errorf("grid hint:\ngot: %q\nwant: %q", got, want) } - if got, want := m.detailKeys(), "esc back k keep x stop f follow"; got != want { + if got, want := m.detailKeys(), "esc back k keep x stop f follow w wrap"; got != want { t.Errorf("detail hint:\ngot: %q\nwant: %q", got, want) } }) diff --git a/cmd/spinloop/dashboard_model.go b/cmd/spinloop/dashboard_model.go index 8604362b..638f790f 100644 --- a/cmd/spinloop/dashboard_model.go +++ b/cmd/spinloop/dashboard_model.go @@ -109,6 +109,7 @@ type dashModel struct { detailLogGen int // bumped on every open, so a reply from a closed or superseded view is discarded detailLogBusy bool // a log round is in flight for the node in view detailLogFollow bool // whether the tick is allowed to start a round; f toggles it + detailLogClip bool // whether long log lines are cut rather than wrapped; w toggles it, and the view opens wrapped, so the zero value is the default detailLogOffset int64 // where the next log round resumes from detailLogContent string // the tailed content, trimmed to what the pane can show detailLogNote string // why the pane has no content — empty once it does @@ -835,7 +836,7 @@ func (m dashModel) detailKeys() string { if m.canAbort() { parts = append(parts, "a abort") } - return strings.Join(append(parts, "f follow"), dashHintGap) + return strings.Join(append(parts, "f follow", "w wrap"), dashHintGap) } // indexOf finds an entry by name. Fleet-file names are unique — the fleet diff --git a/cmd/spinloop/dashboard_render.go b/cmd/spinloop/dashboard_render.go index a28bd775..4cc7a3a0 100644 --- a/cmd/spinloop/dashboard_render.go +++ b/cmd/spinloop/dashboard_render.go @@ -83,7 +83,8 @@ func dashClampScroll(scrollRow, entries, w, h int) int { // dashClip hard-cuts a line at width display columns. It is a cut, not a wrap // — wrapping would push a bar line onto two rows and break the block's // alignment — and it is ANSI-aware (ansi.CutWc), so a colour code mid-line -// survives and a wide character counts as two. +// survives and a wide character counts as two. Log lines are the one pane +// content that does wrap rather than cut; see dashWrapLines. func dashClip(line string, width int) string { if lipgloss.Width(line) <= width { return line @@ -91,6 +92,46 @@ func dashClip(line string, width int) string { return ansi.CutWc(line, 0, width) } +// dashWrapLines breaks a line into rows of at most width display columns the +// way a terminal breaks its own output: hard at the column, not on words — +// log lines are data, not prose, and a JSON blob or a prompt dump may carry +// no space to break on at all. Wide characters count two columns, and a +// cluster that would straddle the edge starts the next row whole rather than +// being split. It sits beside dashClip because the two answer the pane's two +// questions: a bar must be cut to keep its shape, a log line must be wrapped +// to keep its text. A width that cannot hold a single wide glyph leaves the +// rest of the line on one row — there is no column to break at. +func dashWrapLines(line string, width int) []string { + if width < 1 || ansi.StringWidth(line) <= width { + return []string{line} + } + var rows []string + for ansi.StringWidth(line) > width { + head := ansi.TruncateWc(line, width, "") + if head == "" { + return append(rows, line) + } + rows = append(rows, head) + line = ansi.TruncateLeftWc(line, width, "") + } + return append(rows, line) +} + +// dashLogRows expands a pane's log lines into the rows to draw at the pane's +// width: wrapped rows where wrapping is on, single cut rows — the old +// behaviour, one row per line — where it is off. +func dashLogRows(lines []string, width int, wrap bool) []string { + rows := make([]string, 0, len(lines)) + for _, line := range lines { + if wrap { + rows = append(rows, dashWrapLines(line, width)...) + continue + } + rows = append(rows, dashClip(line, width)) + } + return rows +} + // dashHintGap separates one key-help entry from the next, and is what // dashKeyHints splits a hint line on. const dashHintGap = " " diff --git a/cmd/spinloop/dashboard_render_test.go b/cmd/spinloop/dashboard_render_test.go new file mode 100644 index 00000000..6e60f347 --- /dev/null +++ b/cmd/spinloop/dashboard_render_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "strings" + "testing" +) + +// A wrapped line is the terminal's own break: hard at the column, wide +// clusters whole on the next row, and a line the pane can hold arriving as +// the single row it always was. +func TestDashWrapLines(t *testing.T) { + cases := []struct { + name string + line string + width int + want []string + }{ + {"fits as one row", "short", 10, []string{"short"}}, + {"exact multiple", "abcdefgh", 4, []string{"abcd", "efgh"}}, + {"remainder row", "abcdefghij", 4, []string{"abcd", "efgh", "ij"}}, + {"wide cluster moves whole", "a漢b", 2, []string{"a", "漢", "b"}}, + {"empty line stays a row", "", 10, []string{""}}, + {"zero width draws the line whole", "漢漢", 0, []string{"漢漢"}}, + {"pane narrower than one wide glyph", "漢漢", 1, []string{"漢漢"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := dashWrapLines(tc.line, tc.width) + if len(got) != len(tc.want) { + t.Fatalf("rows = %q, want %q", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("rows = %q, want %q", got, tc.want) + } + } + }) + } +} + +// Nothing is lost or duplicated across the break: the rows reassemble the +// line exactly. +func TestDashWrapLinesLosesNothing(t *testing.T) { + line := strings.Repeat("漢x", 60) + rows := dashWrapLines(line, 7) + if strings.Join(rows, "") != line { + t.Errorf("rejoining the rows did not recover the line:\n%q", rows) + } +} + +// Off is today's clip: one row per line, cut at the pane's width. +func TestDashLogRowsClippedVersusWrapped(t *testing.T) { + lines := []string{strings.Repeat("x", 10), "y"} + + wrapped := dashLogRows(lines, 4, true) + if len(wrapped) != 4 || wrapped[0] != "xxxx" || wrapped[3] != "y" { + t.Errorf("wrapped rows = %q", wrapped) + } + + clipped := dashLogRows(lines, 4, false) + if len(clipped) != 2 || clipped[0] != "xxxx" || clipped[1] != "y" { + t.Errorf("clipped rows = %q", clipped) + } +} diff --git a/cmd/spinloop/fleet_dashboard_test.go b/cmd/spinloop/fleet_dashboard_test.go index 01fed3a9..b03836b2 100644 --- a/cmd/spinloop/fleet_dashboard_test.go +++ b/cmd/spinloop/fleet_dashboard_test.go @@ -2667,7 +2667,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: make([]dashAction, 1), }, "↑↓←→ move x stop g format r refresh q quit", - "esc back x stop f follow", + "esc back x stop f follow w wrap", }, { "stopped local node", @@ -2677,7 +2677,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: make([]dashAction, 1), }, "↑↓←→ move s start g format r refresh q quit", - "esc back s start f follow", + "esc back s start f follow w wrap", }, { "unknown state", @@ -2687,7 +2687,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: make([]dashAction, 1), }, "↑↓←→ move s start g format r refresh q quit", - "esc back s start f follow", + "esc back s start f follow w wrap", }, { "a node that never became one", @@ -2698,7 +2698,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: make([]dashAction, 1), }, "↑↓←→ move g format r refresh q quit", - "esc back f follow", + "esc back f follow w wrap", }, { "a start in flight", @@ -2708,7 +2708,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: []dashAction{{verb: "start"}}, }, "↑↓←→ move a abort g format r refresh q quit", - "esc back a abort f follow", + "esc back a abort f follow w wrap", }, { "a stop in flight", @@ -2718,7 +2718,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: []dashAction{{verb: "stop"}}, }, "↑↓←→ move g format r refresh q quit", - "esc back f follow", + "esc back f follow w wrap", }, { "a stopped cloud environment", @@ -2728,7 +2728,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: make([]dashAction, 1), }, "↑↓←→ move s start k keep g format r refresh q quit", - "esc back s start k keep f follow", + "esc back s start k keep f follow w wrap", }, { "a running cloud environment", @@ -2738,7 +2738,7 @@ func TestDashFooterNamesOnlyTheKeysTheNodeTakes(t *testing.T) { actions: make([]dashAction, 1), }, "↑↓←→ move k keep x stop g format r refresh q quit", - "esc back k keep x stop f follow", + "esc back k keep x stop f follow w wrap", }, } for _, tc := range cases { @@ -3253,6 +3253,88 @@ func TestDashDetailViewTruncatesOverflowingLogToAvailableRows(t *testing.T) { } } +// By default the detail view wraps a log line wider than the view rather +// than cutting it: the rows reassemble the line whole, the header names the +// wrapping, and the footer names its key. +func TestDashDetailViewWrapsLongLogLineByDefault(t *testing.T) { + lipgloss.SetColorProfile(termenv.Ascii) + m := dashModel{ + entries: []dashEntry{{name: "n", kind: fleet.KindDaemon, node: newFakeDashNode("running")}}, + results: []fleet.NodeResult{{Name: "n"}}, + actions: make([]dashAction, 1), + detail: true, + detailLogContent: strings.Repeat("x", 200) + "\n", + width: 80, height: 24, + } + v := m.detailView() + if !strings.Contains(v, "wrap: on") { + t.Errorf("the view must open wrapped and say so:\n%s", v) + } + if !strings.Contains(v, "w wrap") { + t.Errorf("the footer does not name the wrap key:\n%s", v) + } + var drawn int + var wide int + for _, line := range strings.Split(v, "\n") { + if line != "" && strings.Trim(line, "x") == "" { + drawn += lipgloss.Width(line) + wide++ + } + } + if drawn != 200 || wide < 3 { + t.Errorf("the long line drew %d columns across %d rows, want all 200 across at least 3", drawn, wide) + } +} + +// w turns the detail view's wrapping off and back on, cutting the long line +// at the view's width while off. +func TestDashDetailViewWrapToggle(t *testing.T) { + lipgloss.SetColorProfile(termenv.Ascii) + m := dashModel{ + entries: []dashEntry{{name: "n", kind: fleet.KindDaemon, node: newFakeDashNode("running")}}, + results: []fleet.NodeResult{{Name: "n"}}, + actions: make([]dashAction, 1), + detail: true, + detailLogContent: strings.Repeat("x", 200) + "\n", + width: 80, height: 24, + } + m.updateDetailKey(dashKey("w")) + v := m.detailView() + if !strings.Contains(v, "wrap: off") { + t.Errorf("the header does not show wrapping off:\n%s", v) + } + var rows []string + for _, line := range strings.Split(v, "\n") { + if line != "" && strings.Trim(line, "x") == "" { + rows = append(rows, line) + } + } + if len(rows) != 1 || lipgloss.Width(rows[0]) != 80 { + t.Errorf("with wrapping off the long line must draw one cut row of the view's width, got %q", rows) + } + + m.updateDetailKey(dashKey("w")) + if v := m.detailView(); !strings.Contains(v, "wrap: on") { + t.Errorf("the header does not show wrapping back on:\n%s", v) + } +} + +// Wrapping is a rendering fact, not a polling one: a standing node that can +// never poll its log still shows the wrap state in its header. +func TestDashDetailViewStandingNodeShowsWrapState(t *testing.T) { + lipgloss.SetColorProfile(termenv.Ascii) + m := dashModel{ + entries: []dashEntry{{name: "broken", kind: fleet.KindDaemon}}, + results: []fleet.NodeResult{{Name: "broken", Outcome: fleet.OutcomeConfigError}}, + actions: make([]dashAction, 1), + detail: true, + width: 80, height: 24, + } + if v := m.detailView(); !strings.Contains(v, "wrap: on") { + t.Errorf("a standing node's header must still name the wrapping:\n%s", v) + } +} + func TestDetailLogLinesPlaceholderAndNote(t *testing.T) { if got := detailLogLines("", ""); len(got) != 1 || got[0] != "waiting for the log…" { t.Errorf("placeholder: %v", got) @@ -3293,7 +3375,7 @@ func TestDashDetailViewRendersMetricsLogAndFooter(t *testing.T) { } // The node is idle with nothing in flight, so the footer names back, // start (the node is not running) and follow — not stop, not abort. - if !strings.Contains(view, "esc back s start f follow") { + if !strings.Contains(view, "esc back s start f follow w wrap") { t.Errorf("footer does not name the detail view's keys:\n%s", view) } } diff --git a/cmd/spinloop/serve_view.go b/cmd/spinloop/serve_view.go index bdb2d744..12411a64 100644 --- a/cmd/spinloop/serve_view.go +++ b/cmd/spinloop/serve_view.go @@ -52,10 +52,10 @@ const serveViewLogTailBytes = 200 * bytesPerLineGuess const serveViewMetricsTimeout = 5 * time.Second // serveViewKeys is the view's footer key help: exactly the keys the view -// answers to — the scroll, the follow, and the quit — and nothing the view -// cannot do. Starting, stopping, keeping and aborting are not among them: -// the engine is serve's own, and leaving is what stops it. -const serveViewKeys = "↑↓ scroll pgup/pgdown page f follow q quit" +// answers to — the scroll, the follow, the wrap, and the quit — and nothing +// the view cannot do. Starting, stopping, keeping and aborting are not among +// them: the engine is serve's own, and leaving is what stops it. +const serveViewKeys = "↑↓ scroll pgup/pgdown page f follow w wrap q quit" // serveView is the program's state: the metrics reading and when it was // taken, the tailed log and where its window sits in it, and the window size @@ -71,6 +71,7 @@ type serveView struct { logBudget int // how many lines the tail retains logBehind int // how many lines the window's bottom sits behind the newest; 0 is on the newest line logFollow bool // whether the poll picks up new output; f pauses and resumes it + logClip bool // whether long lines are cut rather than wrapped; w toggles it, and the view opens wrapped, so the zero value is the default logBusy bool // a poll is in flight logGen int // bumped to supersede a poll in flight logNote string // why the pane has no content — empty once it does, or once a read fails with content in place @@ -194,14 +195,17 @@ func (m *serveView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // updateKey answers the keys the view reads: the scroll of the log pane, f -// to pause and resume the follow, and q or Ctrl+C to leave — which stops the -// engine. Nothing else drives anything in the view, so nothing else is read. +// to pause and resume the follow, w to turn log wrapping on and off, and q +// or Ctrl+C to leave — which stops the engine. Nothing else drives anything +// in the view, so nothing else is read. func (m *serveView) updateKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "q", "ctrl+c": return m.stopCmd() case "f": m.logFollow = !m.logFollow + case "w": + m.logClip = !m.logClip case "up": m.logBehind++ m.clampBehind() @@ -412,9 +416,9 @@ func (m serveView) staleSuffix() string { } // View draws the frame: the title bar — the screen, the Spinloop's path, and -// the log's following or paused state to the right — the metrics section, a -// divider, the tailed log filling the remaining rows, a divider, and the -// footer naming the view's keys. +// the log's following-or-paused and wrapping states to the right — the +// metrics section, a divider, the tailed log filling the remaining rows, a +// divider, and the footer naming the view's keys. func (m serveView) View() string { w := m.effWidth() metricsH, avail := m.sectionHeights() @@ -423,7 +427,11 @@ func (m serveView) View() string { if !m.logFollow { logState = "paused" } - header := dashTitleBar("serve", m.spinloopPath+" log: "+logState, w) + wrapState := "on" + if m.logClip { + wrapState = "off" + } + header := dashTitleBar("serve", m.spinloopPath+" log: "+logState+" wrap: "+wrapState, w) divider := strings.Repeat("─", w) metrics := m.metricsLines() @@ -440,19 +448,20 @@ func (m serveView) View() string { } logLines = logLines[:end] } - if len(logLines) > avail { - logLines = logLines[len(logLines)-avail:] + // The pane counts rows, not lines: a wrapped line contributes as many + // rows as it needs, and the window's newest rows sit at the bottom. + logRows := dashLogRows(logLines, w, !m.logClip) + if len(logRows) > avail { + logRows = logRows[len(logRows)-avail:] } - parts := make([]string, 0, len(metrics)+len(logLines)+4) + parts := make([]string, 0, len(metrics)+len(logRows)+4) parts = append(parts, header, divider) for _, line := range metrics { parts = append(parts, dashClip(line, w)) } parts = append(parts, divider) - for _, line := range logLines { - parts = append(parts, dashClip(line, w)) - } + parts = append(parts, logRows...) parts = append(parts, divider) parts = append(parts, dashClip(dashKeyHints(serveViewKeys), w)) return strings.Join(parts, "\n") diff --git a/cmd/spinloop/serve_view_test.go b/cmd/spinloop/serve_view_test.go index 512790d5..6ff88409 100644 --- a/cmd/spinloop/serve_view_test.go +++ b/cmd/spinloop/serve_view_test.go @@ -536,3 +536,116 @@ func TestServeViewTinyWindowFloorsTheLogPane(t *testing.T) { } func f64ptr(v float64) *float64 { return &v } + +// xRows is the view's rows made entirely of x's, the padded frame's chrome +// none of them: their widths say how a long line of x's was drawn. +func xRows(view string) []string { + var rows []string + for _, line := range strings.Split(view, "\n") { + if line != "" && strings.Trim(line, "x") == "" { + rows = append(rows, line) + } + } + return rows +} + +func serveViewLogReader(content string) func(int64, int) (daemon.LogsResponse, error) { + return func(offset int64, limit int) (daemon.LogsResponse, error) { + return daemon.LogsResponse{Content: content, NextOffset: int64(len(content))}, nil + } +} + +// A line wider than the pane wraps rather than being cut — by default, and +// the header and footer both say so: every column of the long line is drawn, +// split across rows of the pane's width. +func TestServeViewLongLineWraps(t *testing.T) { + m := newTestServeView(nil, serveViewLogReader("alpha\n"+strings.Repeat("x", 140)+"\n")) + m.Update(m.startLogPoll()()) + + v := m.View() + if !strings.Contains(v, "wrap: on") { + t.Errorf("the view must open wrapped and say so:\n%s", v) + } + if !strings.Contains(v, "w wrap") { + t.Errorf("the footer does not name the wrap key:\n%s", v) + } + rows := xRows(v) + if len(rows) != 2 { + t.Fatalf("the long line drew %d rows, want 2:\n%s", len(rows), v) + } + if w0, w1 := lipgloss.Width(rows[0]), lipgloss.Width(rows[1]); w0 != 100 || w1 != 40 { + t.Errorf("rows are %d and %d wide, want 100 and 40:\n%q", w0, w1, rows) + } + if !strings.Contains(v, "alpha") { + t.Errorf("the lines around it went missing:\n%s", v) + } +} + +// w turns wrapping off and back on: off, the long line is cut at the pane's +// width again and the header names the state; on, the wrapped rows return. +func TestServeViewWrapToggle(t *testing.T) { + m := newTestServeView(nil, serveViewLogReader(strings.Repeat("x", 140)+"\n")) + m.Update(m.startLogPoll()()) + + if rows := xRows(m.View()); len(rows) != 2 { + t.Fatalf("the view does not wrap by default: %q", rows) + } + + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + v := m.View() + if !strings.Contains(v, "wrap: off") { + t.Errorf("the header does not show wrapping off:\n%s", v) + } + rows := xRows(v) + if len(rows) != 1 || lipgloss.Width(rows[0]) != 100 { + t.Errorf("with wrapping off the long line must draw one 100-column cut row, got %q", rows) + } + + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + v = m.View() + if !strings.Contains(v, "wrap: on") { + t.Errorf("the header does not show wrapping back on:\n%s", v) + } + if rows := xRows(v); len(rows) != 2 { + t.Errorf("the wrapped rows did not return: %q", rows) + } +} + +// Scrolling moves whole lines: up takes the wrapped line's every row out of +// the pane together, down brings all of them back. +func TestServeViewScrollMovesWholeWrappedLine(t *testing.T) { + var b strings.Builder + for i := 0; i < 39; i++ { + fmt.Fprintf(&b, "n%d\n", i) + } + b.WriteString(strings.Repeat("a", 120) + "\n") + m := newTestServeView(nil, serveViewLogReader(b.String())) + m.Update(m.startLogPoll()()) + + countARows := func(view string) int { + n := 0 + for _, line := range strings.Split(view, "\n") { + if line != "" && strings.Trim(line, "a") == "" { + n++ + } + } + return n + } + if got := countARows(m.View()); got != 2 { + t.Fatalf("the wrapped line's two rows should be in the pane, got %d rows", got) + } + + m.Update(tea.KeyMsg{Type: tea.KeyUp}) + v := m.View() + if got := countARows(v); got != 0 { + t.Errorf("up left %d rows of the wrapped line behind, want none", got) + } + if !strings.Contains(v, "n38") { + t.Errorf("the window did not end on the previous line:\n%s", v) + } + + m.Update(tea.KeyMsg{Type: tea.KeyDown}) + if got := countARows(m.View()); got != 2 { + t.Errorf("down did not bring the wrapped line back whole, got %d rows", got) + } +} diff --git a/openspec/changes/wrap-engine-logs/tasks.md b/openspec/changes/wrap-engine-logs/tasks.md index b7ee5ad3..087f7532 100644 --- a/openspec/changes/wrap-engine-logs/tasks.md +++ b/openspec/changes/wrap-engine-logs/tasks.md @@ -2,39 +2,39 @@ ## 1. Shared wrapper -- [ ] 1.1 Add `dashWrapLines(line string, width int) []string` beside +- [x] 1.1 Add `dashWrapLines(line string, width int) []string` beside `dashClip` in `cmd/spinloop/dashboard_render.go`: hard wrap at exact display columns (wide runes count two), one row when the line fits, plain-text fast path; document why it exists beside the clipper's "cut, not a wrap" comment. -- [ ] 1.2 Unit-test the wrapper: fits-as-one-row, exact multiple, remainder +- [x] 1.2 Unit-test the wrapper: fits-as-one-row, exact multiple, remainder row, wide characters, empty line, width < 1 guard. ## 2. Serve view -- [ ] 2.1 Add a `logWrap bool` to `serveView`, defaulting on in +- [x] 2.1 Add a `logWrap bool` to `serveView`, defaulting on in `runServeView`; read `w` in `updateKey` to toggle it. -- [ ] 2.2 In `View`, expand the window's lines to rows — wrapped rows when +- [x] 2.2 In `View`, expand the window's lines to rows — wrapped rows when on, `dashClip` single rows when off — and take the last `avail` rows; show `wrap: on/off` beside the log state in the header. -- [ ] 2.3 Add `w wrap` to `serveViewKeys` footer help. -- [ ] 2.4 Tests: long line wraps across rows and is drawn in full; `w` +- [x] 2.3 Add `w wrap` to `serveViewKeys` footer help. +- [x] 2.4 Tests: long line wraps across rows and is drawn in full; `w` toggles to clipping and back; header shows the state; scrolling moves whole wrapped lines; existing clip-shaped tests updated to the wrapped default. ## 3. Dashboard detail view -- [ ] 3.1 Add a `detailLogWrap bool` to `dashModel` (set in `openDetail`); +- [x] 3.1 Add a `detailLogWrap bool` to `dashModel` (set in `openDetail`); read `w` in `updateDetailKey`. -- [ ] 3.2 In `detailView`, expand log lines to rows the same shared way, +- [x] 3.2 In `detailView`, expand log lines to rows the same shared way, slice to the pane's rows, and show the wrap state in the header for every node, standing ones included. -- [ ] 3.3 Add `w wrap` to `detailKeys`. -- [ ] 3.4 Tests: wrapping default, `w` toggle, header state, long line in +- [x] 3.3 Add `w wrap` to `detailKeys`. +- [x] 3.4 Tests: wrapping default, `w` toggle, header state, long line in full; update `TestDashDetailViewTruncatesOverflowingLogToAvailableRows` for the wrapped default. ## 4. Gate -- [ ] 4.1 `gofmt`, `go vet`, `go test ./...` green with coverage ≥ 80%. +- [x] 4.1 `gofmt`, `go vet`, `go test ./...` green with coverage ≥ 80%. From 0ffb8bd10932277181966fe2ada4445074dce0d8 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 26 Oct 2026 15:51:43 +0000 Subject: [PATCH 3/5] test: pin the detail pane's row-sliced tail of a wrapped log line --- cmd/spinloop/fleet_dashboard_test.go | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cmd/spinloop/fleet_dashboard_test.go b/cmd/spinloop/fleet_dashboard_test.go index b03836b2..021eb6df 100644 --- a/cmd/spinloop/fleet_dashboard_test.go +++ b/cmd/spinloop/fleet_dashboard_test.go @@ -3335,6 +3335,39 @@ func TestDashDetailViewStandingNodeShowsWrapState(t *testing.T) { } } +// When the wrapped rows overflow a short pane the tail slices by rows, not +// lines: the pane shows the newest rows, bottom-anchored, and keeps the very +// end of the newest line. +func TestDashDetailViewSlicesWrappedRowsToThePane(t *testing.T) { + lipgloss.SetColorProfile(termenv.Ascii) + m := dashModel{ + entries: []dashEntry{{name: "n", kind: fleet.KindDaemon, node: newFakeDashNode("running")}}, + results: []fleet.NodeResult{{Name: "n"}}, // a 2-line metrics section + actions: make([]dashAction, 1), + detail: true, + detailLogContent: "older\n" + strings.Repeat("x", 200) + "\n", + width: 80, height: 9, // avail = 9 - 5 - 2 = 2 + } + v := m.detailView() + var rows []string + for _, line := range strings.Split(v, "\n") { + if line != "" && strings.Trim(line, "x") == "" { + rows = append(rows, line) + } + } + // Two rows fit: 80 of the line, then its last 40 — the pane's bottom + // row is the line's tail, and the row older than the pane is gone. + if len(rows) != 2 { + t.Fatalf("the pane drew %d x-rows, want 2:\n%s", len(rows), v) + } + if w0, w1 := lipgloss.Width(rows[0]), lipgloss.Width(rows[1]); w0 != 80 || w1 != 40 { + t.Errorf("the pane's rows are %d and %d wide, want 80 then 40:\n%s", w0, w1, v) + } + if strings.Contains(v, "older") { + t.Errorf("a line pushed above the pane must not be drawn:\n%s", v) + } +} + func TestDetailLogLinesPlaceholderAndNote(t *testing.T) { if got := detailLogLines("", ""); len(got) != 1 || got[0] != "waiting for the log…" { t.Errorf("placeholder: %v", got) From e984841eb812f12f3219570ecc1cd0241dc2163f Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 26 Oct 2026 15:51:43 +0000 Subject: [PATCH 4/5] docs: document the log wrapping and its w key in both views --- docs/commands/dashboard.md | 4 ++++ docs/commands/serve.md | 12 ++++++++---- docs/maintainer/internals.md | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/commands/dashboard.md b/docs/commands/dashboard.md index af87e6be..e2c5fcf4 100644 --- a/docs/commands/dashboard.md +++ b/docs/commands/dashboard.md @@ -31,6 +31,10 @@ metrics view cannot word the same reading differently. environment carries its deadline beside the last-active line on both the tile and the detail view, whatever the engine's state. +In the detail view the engine's log wraps long lines to the pane's width by +default, so none of a line is cut; `w` turns that off and back on, and the +title bar names the state beside the log's follow state. + ## Which target The three ways every command that acts on a fleet takes one — `--env `, diff --git a/docs/commands/serve.md b/docs/commands/serve.md index b63de770..6045d973 100644 --- a/docs/commands/serve.md +++ b/docs/commands/serve.md @@ -34,8 +34,9 @@ and a footer naming the keys the view answers to. process runs in-process and refreshes on the dashboard's own local cadence; a reading the view could not renew is shown with its age. - **Log** — the engine's own output, tailed and followed, so new lines appear - as they are written. An engine that has written nothing yet shows a waiting - note, not an empty pane. + as they are written. A line wider than the pane wraps onto continuation + rows rather than being cut, so none of it is lost. An engine that has + written nothing yet shows a waiting note, not an empty pane. - **Footer** — the view's keys, and nothing the view cannot do. Starting, stopping, keeping and aborting are not among them: the engine is serve's own, and leaving is what stops it. @@ -45,12 +46,15 @@ and a footer naming the keys the view answers to. | `↑` / `↓` | Scroll the log one line; a press at either end leaves the window where it is | | `pgup` / `pgdown` | Scroll the log by a page | | `f` | Pause and resume the log's follow — the metrics section keeps refreshing either way | +| `w` | Turn log wrapping on and off — off cuts a long line at the pane's width | | `q` or `Ctrl+C` | Leave — stops the engine and exits serve | While the log's window is on the newest line it sticks to the tail: new output appears as it is written. Scrolled away from it, the window stays put -and the new lines accrue behind it. Pausing the follow holds the window, and -resuming fetches whatever the engine wrote in the meantime — nothing is lost. +and the new lines accrue behind it. Scrolling moves by lines, so a wrapped +line leaves and returns to the pane whole, its rows together. Pausing the +follow holds the window, and resuming fetches whatever the engine wrote in +the meantime — nothing is lost. The engine's own exit closes the view and serve exits with the engine's exit status, exactly as a foreground serve does. diff --git a/docs/maintainer/internals.md b/docs/maintainer/internals.md index a3ab990b..0a850b79 100644 --- a/docs/maintainer/internals.md +++ b/docs/maintainer/internals.md @@ -62,6 +62,7 @@ A few Bubble Tea/lipgloss specifics that are easy to break by "simplifying": - A tile's first line is a header bar drawn in raw ANSI — the body is one plain string under a single lipgloss style, so per-character colour cannot be lipgloss's. The board's own title bar (`dashTitleBar`) uses lipgloss instead, and the two share one surface index (`barSurface`) because they are set through different mechanisms and would otherwise drift. - A grid row joins the *corresponding lines* of the tiles it places, not the tile blocks — joining whole blocks glues the second tile's top border to the first tile's bottom border and shifts its body down a line. - A tile's content is exactly the lines `metrics` prints for the node in the board's current format (`renderStatBars`/`renderStatGauges`/`renderTokenLines` are shared, not reimplemented), so the panel and `metrics` can never disagree on a number. The format is board-wide, in `dashModel.gauge`, toggled by `g`, opening in gauge; the tile draws the sparkline at `dashBarLineW`, chosen so a full row (label, glyphs, trailing percentage) fits the tile's width exactly. +- The log panes (the detail view's and the serve view's) count *rows* at draw time and *lines* everywhere else: `dashLogRows` expands the window's lines to wrapped rows (`dashWrapLines` breaks at exact display columns — `ansi.CutWc` with a non-zero `left` misbehaves on wide runes in the pinned `charmbracelet/x/ansi`, which is why the wrapper cuts heads with `TruncateWc` and tails with `TruncateLeftWc`), the pane takes the last `avail` rows, and retention budgets (`logBudget`, `detailLogCapacity`) stay in lines. Scrolling stays line-based (`logBehind`) so a wrapped line moves whole; do not "tidy" one granularity into the other. Metrics lines keep clipping — `dashClip`'s cut is what keeps the grid and the bar lines the shape of the frame. Behavior (panel contents, refresh cadence, start/stop/abort semantics, the detail view) is specified in `openspec/specs/fleet-client/spec.md`. From d07f8cf7d58042c32519b13b9863818c0b00b2e6 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Wed, 28 Oct 2026 01:05:14 +0000 Subject: [PATCH 5/5] docs(openspec): archive the wrap-engine-logs change --- .../.openspec.yaml | 0 .../2026-10-28-wrap-engine-logs}/design.md | 0 .../2026-10-28-wrap-engine-logs}/proposal.md | 0 .../specs/fleet-client/spec.md | 0 .../specs/local-serving/spec.md | 0 .../2026-10-28-wrap-engine-logs}/tasks.md | 0 openspec/specs/fleet-client/spec.md | 21 ++++++++++ openspec/specs/local-serving/spec.md | 38 ++++++++++++++++--- 8 files changed, 54 insertions(+), 5 deletions(-) rename openspec/changes/{wrap-engine-logs => archive/2026-10-28-wrap-engine-logs}/.openspec.yaml (100%) rename openspec/changes/{wrap-engine-logs => archive/2026-10-28-wrap-engine-logs}/design.md (100%) rename openspec/changes/{wrap-engine-logs => archive/2026-10-28-wrap-engine-logs}/proposal.md (100%) rename openspec/changes/{wrap-engine-logs => archive/2026-10-28-wrap-engine-logs}/specs/fleet-client/spec.md (100%) rename openspec/changes/{wrap-engine-logs => archive/2026-10-28-wrap-engine-logs}/specs/local-serving/spec.md (100%) rename openspec/changes/{wrap-engine-logs => archive/2026-10-28-wrap-engine-logs}/tasks.md (100%) diff --git a/openspec/changes/wrap-engine-logs/.openspec.yaml b/openspec/changes/archive/2026-10-28-wrap-engine-logs/.openspec.yaml similarity index 100% rename from openspec/changes/wrap-engine-logs/.openspec.yaml rename to openspec/changes/archive/2026-10-28-wrap-engine-logs/.openspec.yaml diff --git a/openspec/changes/wrap-engine-logs/design.md b/openspec/changes/archive/2026-10-28-wrap-engine-logs/design.md similarity index 100% rename from openspec/changes/wrap-engine-logs/design.md rename to openspec/changes/archive/2026-10-28-wrap-engine-logs/design.md diff --git a/openspec/changes/wrap-engine-logs/proposal.md b/openspec/changes/archive/2026-10-28-wrap-engine-logs/proposal.md similarity index 100% rename from openspec/changes/wrap-engine-logs/proposal.md rename to openspec/changes/archive/2026-10-28-wrap-engine-logs/proposal.md diff --git a/openspec/changes/wrap-engine-logs/specs/fleet-client/spec.md b/openspec/changes/archive/2026-10-28-wrap-engine-logs/specs/fleet-client/spec.md similarity index 100% rename from openspec/changes/wrap-engine-logs/specs/fleet-client/spec.md rename to openspec/changes/archive/2026-10-28-wrap-engine-logs/specs/fleet-client/spec.md diff --git a/openspec/changes/wrap-engine-logs/specs/local-serving/spec.md b/openspec/changes/archive/2026-10-28-wrap-engine-logs/specs/local-serving/spec.md similarity index 100% rename from openspec/changes/wrap-engine-logs/specs/local-serving/spec.md rename to openspec/changes/archive/2026-10-28-wrap-engine-logs/specs/local-serving/spec.md diff --git a/openspec/changes/wrap-engine-logs/tasks.md b/openspec/changes/archive/2026-10-28-wrap-engine-logs/tasks.md similarity index 100% rename from openspec/changes/wrap-engine-logs/tasks.md rename to openspec/changes/archive/2026-10-28-wrap-engine-logs/tasks.md diff --git a/openspec/specs/fleet-client/spec.md b/openspec/specs/fleet-client/spec.md index 9a840ebf..af8f2eac 100644 --- a/openspec/specs/fleet-client/spec.md +++ b/openspec/specs/fleet-client/spec.md @@ -1059,6 +1059,14 @@ node whose engine has not run yet, or that cannot supply its log, SHALL show the same explanation the `fleet logs` command gives for it, rather than an empty pane. +A log line longer than the view's width SHALL wrap onto continuation rows at +the left margin, so the whole line is readable within the view; wrapping +SHALL be on by default. The operator SHALL be able to turn wrapping off and +back on from the keyboard with the same key the serve view answers to; while +wrapping is off a longer line SHALL be cut at the view's width as before. The +view SHALL show whether wrapping is on or off. The pane's rows are then its +lines expanded to their wrapped rows, the newest rows at the pane's bottom. + The operator SHALL be able to pause and resume the log's follow from the keyboard, independently of the rest of the view: while paused, the log section SHALL stop picking up new output, and the view SHALL show whether the @@ -1079,6 +1087,19 @@ affect the metrics section's own refresh, or any node's start, stop or abort. - **THEN** the new lines appear in the log section without the operator pressing any key +#### Scenario: A line longer than the view wraps + +- **WHEN** the log shows a line wider than the view's width +- **THEN** the line is drawn across as many rows as it needs, every row + starting at the left margin, with no part of the line lost + +#### Scenario: Turning wrapping off and on + +- **WHEN** the operator turns wrapping off +- **THEN** lines longer than the view are cut at its width again, and the + view shows that wrapping is off +- **AND** turning it back on wraps those lines again + #### Scenario: A node with no log yet - **WHEN** the detail view is open on a node whose engine has never run diff --git a/openspec/specs/local-serving/spec.md b/openspec/specs/local-serving/spec.md index 01c3eb3f..d2c2ebad 100644 --- a/openspec/specs/local-serving/spec.md +++ b/openspec/specs/local-serving/spec.md @@ -442,6 +442,14 @@ the same way the dashboard's detail view follows its node's log: new output appears while the view is open without the operator asking for it. An engine that has written nothing yet SHALL show a waiting note, not an empty pane. +A log line longer than the pane's width SHALL wrap onto continuation rows at +the pane's left margin, so the whole line is readable within the pane; +wrapping SHALL be on by default. The operator SHALL be able to turn wrapping +off and back on from the keyboard; while wrapping is off a longer line SHALL +be cut at the pane's width as before. The view SHALL show whether wrapping is +on or off. When wrapping is on, the pane's rows are its window's lines +expanded to their wrapped rows, the newest rows at the pane's bottom. + The up and down arrow keys SHALL scroll the log pane: up moves the visible window one line towards the oldest retained line, down one line towards the newest; a press at either end SHALL leave the window where it is. Page up and @@ -465,6 +473,25 @@ not affect the metrics section's own refresh. - **THEN** the new lines appear in the log section without the operator pressing any key +#### Scenario: A line longer than the pane wraps + +- **WHEN** the engine writes a line wider than the log pane +- **THEN** the line is drawn across as many rows as it needs, every row + starting at the pane's left margin, with no part of the line lost + +#### Scenario: Turning wrapping off and on + +- **WHEN** the operator turns wrapping off +- **THEN** lines longer than the pane are cut at its width again, and the + view shows that wrapping is off +- **AND** turning it back on wraps those lines again + +#### Scenario: Scrolling a wrapped line + +- **WHEN** the operator scrolls the pane by one line +- **THEN** the whole wrapped line — all of its rows — moves out of or into + the pane as one line + #### Scenario: Scrolling the log - **WHEN** the operator presses the up arrow @@ -499,9 +526,10 @@ not affect the metrics section's own refresh. The view SHALL name its keys on screen and offer only the ones that would do something in it: the up and down arrows and page up and page down scroll the -log, `f` pauses and resumes the log's follow, and `q` or Ctrl+C leaves. The -view SHALL NOT offer start, stop, keep or abort keys: the engine is serve's -own — starting is serve's job, and stopping it is what leaving does. +log, `f` pauses and resumes the log's follow, `w` turns log wrapping on and +off, and `q` or Ctrl+C leaves. The view SHALL NOT offer start, stop, keep or +abort keys: the engine is serve's own — starting is serve's job, and stopping +it is what leaving does. `q` and Ctrl+C SHALL stop the engine — gracefully, escalating as a stop does elsewhere — and exit serve. When the engine exits on its own, whatever the @@ -511,8 +539,8 @@ status, exactly as a foreground serve does today. #### Scenario: The key help names only live keys - **WHEN** the view draws its key help line -- **THEN** it names the scroll, follow and quit keys, and nothing the view - cannot do +- **THEN** it names the scroll, follow, wrap and quit keys, and nothing the + view cannot do #### Scenario: Quitting stops the engine