From 0d8168925e6833376573484a41ef6524a85efac9 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 29 Aug 2026 11:41:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20next-3=20round=20=E2=80=94=20atten?= =?UTF-8?q?tion=20layer,=20linear=20mode,=20diff=20fences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three panel-ranked improvements, each TDD'd with the README synced: - attention: turn completion and pending approvals set the terminal window title and ring the bell (--bel=false mutes); --notify adds OSC 9 desktop notifications. One raw tea.Exec write, terminal states only — never per token. - --plain: linear mode without the alt-screen; agent events print append-only to the native scrollback above a minimal chrome, with text-prefix severity for screen readers and pipes. NO_COLOR now deterministically degrades the palette. - diff fences: fenced ```diff blocks tint inside prose without mis-styling it, fences without @@ hunk headers render as diffs, and the +N −M step chip counts fenced stats. --- README.md | 21 ++++ cmd/bodek/main.go | 35 +++++- cmd/bodek/main_test.go | 44 ++++++- internal/tui/attention.go | 85 +++++++++++++ internal/tui/attention_test.go | 185 ++++++++++++++++++++++++++++ internal/tui/clipboard.go | 17 +-- internal/tui/clipboard_test.go | 4 +- internal/tui/events.go | 7 +- internal/tui/input.go | 7 +- internal/tui/model.go | 23 +++- internal/tui/plain.go | 137 ++++++++++++++++++++ internal/tui/plain_test.go | 172 ++++++++++++++++++++++++++ internal/tui/renderers.go | 107 +++++++++++++++- internal/tui/renderers_diff_test.go | 112 +++++++++++++++++ internal/tui/view.go | 20 +++ 15 files changed, 953 insertions(+), 23 deletions(-) create mode 100644 internal/tui/attention.go create mode 100644 internal/tui/attention_test.go create mode 100644 internal/tui/plain.go create mode 100644 internal/tui/plain_test.go create mode 100644 internal/tui/renderers_diff_test.go diff --git a/README.md b/README.md index 32c780d..fb1a1ee 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token bodek --odek-bin ./odek # use a specific odek binary bodek --mouse # enable mouse wheel scrolling (blocks text selection) +bodek --bel=false # mute the attention bell (title still updates) +bodek --notify # desktop notifications (OSC 9) on turn/approval events +bodek --plain # linear mode: transcript to scrollback (a11y, pipes) bodek -- --prompt-caching # pass extra flags through to `odek serve` bodek version # print the bodek version bodek upgrade # download and install the latest release @@ -320,6 +323,24 @@ one `Esc`. quiet status lines. Nothing lingers: info traces fade after 3s, and errors, warnings, and disconnect notes autoclose after 10s (connection state stays visible in the header badge). +- **Attention when backgrounded** — turn completion and pending approvals set + the terminal window title (`✓ done — ` / `⚠ approval needed — + `) and ring the bell (`--bel=false` mutes); `--notify` adds OSC 9 + desktop notifications. Fires only on terminal states — never per token. +- **Linear mode (`--plain`)** — skips the alt-screen entirely: agent events + print as append-only text in the terminal's native scrollback above a + minimal input chrome (`▸` tool calls, `[think]`, `[error]`, `⚠ approval`, + `❯` your prompts, `✓ done · N tools · Xs · N tok`). Severity never rides + color alone, which makes this the accessible surface for screen readers — + and the natural one for pipes: `bodek --plain < task > run.log`. Streamed + fragments stay suppressed; the reply lands whole when the turn ends. + `NO_COLOR` degrades the entire EMBER palette to plain text in every mode. +- **Diff-aware tool steps** — expanding a step (`^E`) renders its output by + shape: unified diffs tint (`+` green, `-` red, hunk headers steel, file + markers dim) with a `+N −M` chip on the step head, fenced ` ```diff ` + blocks tint inside prose without mis-styling the surrounding text, file + reads get line numbers, JSON pretty-prints, and test runs summarize + pass/fail on the step line. - **Version display** — the header shows bodek's own version next to the logo and the spawned odek's version next to the model name. - **Update hint** — at startup, a quiet note appears when a newer bodek release diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index 829cfc8..845662f 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -13,6 +13,8 @@ import ( "syscall" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/BackendStack21/bodek/internal/client" "github.com/BackendStack21/bodek/internal/server" @@ -32,6 +34,9 @@ type config struct { sandbox bool bin string mouse bool + bel bool // terminal bell on turn completion / approval waiting + notify bool // desktop notifications (OSC 9) on the same events + plain bool // linear rendering mode (no alt-screen) extraArgs []string } @@ -46,6 +51,9 @@ func parseConfig(args []string, output io.Writer) (config, error) { fs.BoolVar(&cfg.sandbox, "sandbox", false, "run tool calls inside odek's Docker sandbox") fs.StringVar(&cfg.bin, "odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)") fs.BoolVar(&cfg.mouse, "mouse", false, "enable mouse wheel scrolling (disables native text selection/copy)") + fs.BoolVar(&cfg.bel, "bel", true, "ring the terminal bell when a turn completes or an approval is waiting (--bel=false mutes)") + fs.BoolVar(&cfg.notify, "notify", false, "raise desktop notifications (OSC 9) on turn completion and approvals") + fs.BoolVar(&cfg.plain, "plain", false, "linear mode: no alt-screen, transcript printed to scrollback (screen readers, pipes, logs)") fs.Usage = func() { _, _ = fmt.Fprintf(fs.Output(), "Usage: bodek [options] [-- ]\n\n") _, _ = fmt.Fprintf(fs.Output(), "A terminal interface for the odek agent.\n\n") @@ -60,6 +68,8 @@ func parseConfig(args []string, output io.Writer) (config, error) { _, _ = fmt.Fprintf(fs.Output(), " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n") _, _ = fmt.Fprintf(fs.Output(), " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n") _, _ = fmt.Fprintf(fs.Output(), " bodek --mouse # enable mouse wheel scrolling (blocks text selection)\n") + _, _ = fmt.Fprintf(fs.Output(), " bodek --notify # desktop notifications on turn/approval events\n") + _, _ = fmt.Fprintf(fs.Output(), " bodek --plain # linear mode: transcript to scrollback (pipes, a11y)\n") _, _ = fmt.Fprintf(fs.Output(), " bodek -- --prompt-caching # pass extra flags to odek serve\n") } if err := fs.Parse(args); err != nil { @@ -70,14 +80,29 @@ func parseConfig(args []string, output io.Writer) (config, error) { return cfg, nil } -func buildProgramOptions(mouse bool) []tea.ProgramOption { - opts := []tea.ProgramOption{tea.WithAltScreen()} +func buildProgramOptions(mouse, plain bool) []tea.ProgramOption { + var opts []tea.ProgramOption + if !plain { + // The alt-screen transcript is the default surface. Linear mode + // (--plain) stays on the main buffer so printed lines persist in + // the terminal's native scrollback. + opts = append(opts, tea.WithAltScreen()) + } if mouse { opts = append(opts, tea.WithMouseCellMotion()) } return opts } +// applyNoColor honors https://no-color.org deterministically: when the +// variable is set, the whole palette (EMBER gradients included) degrades +// to plain text regardless of what the terminal advertises. +func applyNoColor() { + if os.Getenv("NO_COLOR") != "" { + lipgloss.SetColorProfile(termenv.Ascii) + } +} + func run() error { // Bare subcommands (`bodek version`, `bodek upgrade`) bypass the TUI // entirely, so they run before flag parsing. @@ -89,6 +114,7 @@ func run() error { if err != nil { return err } + applyNoColor() // A spawned `odek serve` logs to stderr. Routing that to our own terminal // would corrupt the Bubble Tea alt-screen (stray writes desync the diff @@ -154,6 +180,9 @@ func run() error { LogPath: logPath, OdekVersion: srv.Version, Version: currentVersion(), + Bell: cfg.bel, + Notify: cfg.notify, + Plain: cfg.plain, Reconnect: func() (*client.Client, error) { return client.Dial(srv.WSURL, srv.Origin, srv.BaseURL, srv.Token) }, @@ -163,7 +192,7 @@ func run() error { // captures the terminal mouse and blocks native click-drag text selection // and copy. Keep it off by default so users can copy freely; enable it only // when explicitly requested with --mouse. - p := tea.NewProgram(model, buildProgramOptions(cfg.mouse)...) + p := tea.NewProgram(model, buildProgramOptions(cfg.mouse, cfg.plain)...) if _, err := p.Run(); err != nil { return fmt.Errorf("TUI exited: %w", err) } diff --git a/cmd/bodek/main_test.go b/cmd/bodek/main_test.go index bbaed43..abbf8ac 100644 --- a/cmd/bodek/main_test.go +++ b/cmd/bodek/main_test.go @@ -8,6 +8,8 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" ) func TestParseConfigDefaults(t *testing.T) { @@ -18,8 +20,11 @@ func TestParseConfigDefaults(t *testing.T) { if cfg.url != "" || cfg.token != "" || cfg.bin != "" { t.Errorf("unexpected non-empty defaults: %+v", cfg) } - if cfg.sandbox || cfg.mouse { - t.Errorf("expected sandbox and mouse to be false by default, got sandbox=%v mouse=%v", cfg.sandbox, cfg.mouse) + if cfg.sandbox || cfg.mouse || cfg.notify { + t.Errorf("expected sandbox, mouse, notify false by default, got sandbox=%v mouse=%v notify=%v", cfg.sandbox, cfg.mouse, cfg.notify) + } + if !cfg.bel { + t.Error("expected bel to default to true (attention bell on)") } if len(cfg.extraArgs) != 0 { t.Errorf("expected no extra args, got %v", cfg.extraArgs) @@ -36,6 +41,19 @@ func TestParseConfigMouseFlag(t *testing.T) { } } +func TestParseConfigAttentionFlags(t *testing.T) { + cfg, err := parseConfig([]string{"--bel=false", "--notify"}, io.Discard) + if err != nil { + t.Fatalf("parseConfig returned error: %v", err) + } + if cfg.bel { + t.Error("expected --bel=false to mute the attention bell") + } + if !cfg.notify { + t.Error("expected --notify to enable desktop notifications") + } +} + func TestParseConfigExtraArgs(t *testing.T) { cfg, err := parseConfig([]string{"--mouse", "--", "--prompt-caching", "--verbose"}, io.Discard) if err != nil { @@ -74,7 +92,7 @@ func TestParseConfigHelp(t *testing.T) { } func TestBuildProgramOptionsDefault(t *testing.T) { - opts := buildProgramOptions(false) + opts := buildProgramOptions(false, false) if len(opts) != 1 { t.Fatalf("expected 1 default program option, got %d", len(opts)) } @@ -85,8 +103,26 @@ func TestBuildProgramOptionsDefault(t *testing.T) { } func TestBuildProgramOptionsWithMouse(t *testing.T) { - opts := buildProgramOptions(true) + opts := buildProgramOptions(true, false) if len(opts) != 2 { t.Fatalf("expected 2 program options with mouse, got %d", len(opts)) } } + +func TestBuildProgramOptionsPlain(t *testing.T) { + if opts := buildProgramOptions(false, true); len(opts) != 0 { + t.Fatalf("plain mode must skip the alt-screen, got %d options", len(opts)) + } + if opts := buildProgramOptions(true, true); len(opts) != 1 { + t.Fatalf("plain+mouse = %d options, want mouse only", len(opts)) + } +} + +func TestApplyNoColor(t *testing.T) { + defer lipgloss.SetColorProfile(termenv.TrueColor) + t.Setenv("NO_COLOR", "1") + applyNoColor() + if lipgloss.ColorProfile() != termenv.Ascii { + t.Error("NO_COLOR did not degrade the color profile to Ascii") + } +} diff --git a/internal/tui/attention.go b/internal/tui/attention.go new file mode 100644 index 0000000..3d88445 --- /dev/null +++ b/internal/tui/attention.go @@ -0,0 +1,85 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +// attentionKind selects the terminal state the attention layer announces. +type attentionKind int + +const ( + attentionDone attentionKind = iota // a turn finished (done event) + attentionApproval // an approval is waiting (approval_request) +) + +// attention is the plan of terminal-attention effects for one state change. +// The window title always updates — it is silent and keeps tmux/terminal +// window lists truthful. The bell (--bel=false mutes) and the desktop +// notification (--notify enables, OSC 9) are user-gated. Fires only on +// terminal states, never on streamed tokens. +type attention struct { + title string // OSC 0 window title ("" = leave the title alone) + bell bool // ring the terminal bell + notify string // OSC 9 desktop notification text ("" = none) +} + +func (a attention) empty() bool { + return a.title == "" && !a.bell && a.notify == "" +} + +// sequence renders the plan as one raw terminal write: title, notification, +// bell — in that order. Every embedded string is pre-sanitized by the +// planner, so control bytes from the wire cannot inject escapes here. +func (a attention) sequence() string { + var b strings.Builder + if a.title != "" { + b.WriteString(ansi.SetWindowTitle(a.title)) + } + if a.notify != "" { + b.WriteString("\x1b]9;" + a.notify + "\x07") + } + if a.bell { + b.WriteString("\a") + } + return b.String() +} + +// attentionFor decides the attention plan for a terminal state. Wire-borne +// pieces (the model name) go through collapse() — sanitize + whitespace +// flatten — because both consumers are raw escape-sequence payloads. +func (m *Model) attentionFor(kind attentionKind) attention { + model := collapse(m.model) + var prefix, note string + switch kind { + case attentionApproval: + prefix, note = "⚠ approval needed", "bodek: approval needed" + case attentionDone: + prefix, note = "✓ done", "bodek: turn complete" + default: + return attention{} + } + a := attention{bell: m.bell} + if model != "" { + a.title = prefix + " — " + model + a.notify = note + " — " + model + } else { + a.title = prefix + a.notify = note + } + if !m.notify { + a.notify = "" + } + return a +} + +// attentionCmd materializes the plan as a single raw write via tea.Exec — +// the same escape hatch as the OSC 52 clipboard write. Nil when all-muted. +func (m *Model) attentionCmd(a attention) tea.Cmd { + if a.empty() { + return nil + } + return tea.Exec(&rawSeq{seq: a.sequence()}, nil) +} diff --git a/internal/tui/attention_test.go b/internal/tui/attention_test.go new file mode 100644 index 0000000..76d240e --- /dev/null +++ b/internal/tui/attention_test.go @@ -0,0 +1,185 @@ +package tui + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/BackendStack21/bodek/internal/client" +) + +// The attention layer keeps backgrounded panes truthful: when a turn +// completes or an approval starts waiting, bodek sets the terminal window +// title (silent, always on), rings the terminal bell (default on, +// --bel=false mutes it), and optionally raises a desktop notification via +// OSC 9 (--notify). All effects ride a single raw write through tea.Exec +// so the sequence lands verbatim and frames can't interleave — the same +// escape hatch the OSC 52 clipboard write uses. + +func TestAttentionPlanApproval(t *testing.T) { + m := newTestModel() + m.model = "deepseek-v4" + m.bell = true + m.notify = true + + a := m.attentionFor(attentionApproval) + if !strings.HasPrefix(a.title, "⚠ approval needed — ") { + t.Errorf("approval title = %q, want the ⚠ approval-needed prefix", a.title) + } + if !strings.Contains(a.title, "deepseek-v4") { + t.Errorf("approval title %q missing the model name", a.title) + } + if !a.bell { + t.Error("approval plan must ring the bell when enabled") + } + if a.notify == "" { + t.Error("approval plan must carry a desktop notification when --notify is on") + } +} + +func TestAttentionPlanDone(t *testing.T) { + m := newTestModel() + m.model = "deepseek-v4" + m.bell = true + m.notify = true + + a := m.attentionFor(attentionDone) + if !strings.HasPrefix(a.title, "✓ done — ") { + t.Errorf("done title = %q, want the ✓ done prefix", a.title) + } + if !strings.Contains(a.title, "deepseek-v4") { + t.Errorf("done title %q missing the model name", a.title) + } + if !a.bell || a.notify == "" { + t.Errorf("done plan = %+v, want bell and notify when enabled", a) + } +} + +func TestAttentionPlanMuted(t *testing.T) { + m := newTestModel() + m.model = "m" + m.bell = false + m.notify = false + + for _, kind := range []attentionKind{attentionApproval, attentionDone} { + a := m.attentionFor(kind) + if a.bell { + t.Errorf("kind %d: bell fired while muted", kind) + } + if a.notify != "" { + t.Errorf("kind %d: notification fired while disabled", kind) + } + if a.title == "" { + // The title is the silent half of the signal: it stays on even + // with the bell muted, so tmux window lists remain truthful. + t.Errorf("kind %d: muted plan still carries a title", kind) + } + } +} + +func TestAttentionPlanEmptyModel(t *testing.T) { + // No model name known yet (pre-session): the title must stand alone + // instead of trailing a dangling separator. + m := newTestModel() + m.bell = false + + a := m.attentionFor(attentionDone) + if a.title != "✓ done" { + t.Errorf("done title with no model = %q, want %q", a.title, "✓ done") + } +} + +func TestAttentionPlanSanitizesModelName(t *testing.T) { + // The model name arrives from the wire: control bytes must not survive + // into terminal escape sequences (title or OSC 9 payload). + m := newTestModel() + m.model = "evil\x1b]0;pwned\x07" + m.bell = true + m.notify = true + + a := m.attentionFor(attentionApproval) + if strings.ContainsAny(a.title, "\x1b\x07\n\t") { + t.Errorf("title carried control bytes from the wire: %q", a.title) + } + if strings.ContainsAny(a.notify, "\x1b\x07\n\t") { + t.Errorf("notify text carried control bytes from the wire: %q", a.notify) + } +} + +func TestAttentionSequence(t *testing.T) { + // One raw write: title first, notification second, bell last. + got := (attention{title: "T", notify: "N", bell: true}).sequence() + want := ansi.SetWindowTitle("T") + "\x1b]9;N\x07" + "\a" + if got != want { + t.Errorf("sequence = %q, want %q", got, want) + } + + titleOnly := (attention{title: "T"}).sequence() + if titleOnly != ansi.SetWindowTitle("T") { + t.Errorf("title-only sequence = %q, want %q", titleOnly, ansi.SetWindowTitle("T")) + } +} + +func TestAttentionEmptyPlanIsSilent(t *testing.T) { + a := attention{} + if !a.empty() { + t.Error("zero plan must report empty") + } + if seq := a.sequence(); seq != "" { + t.Errorf("empty plan produced sequence %q", seq) + } + if cmd := newTestModel().attentionCmd(a); cmd != nil { + t.Error("empty plan returned a non-nil cmd") + } +} + +func TestAttentionCmdWrapsTheRawWrite(t *testing.T) { + m := newTestModel() + if cmd := m.attentionCmd(attention{title: "T", bell: true}); cmd == nil { + t.Fatal("enabled plan returned a nil cmd") + } + if cmd := m.attentionCmd(attention{bell: true}); cmd == nil { + t.Error("bell-only plan returned a nil cmd") + } +} + +func TestRawSeqRunWritesVerbatim(t *testing.T) { + // The attention write shares the clipboard's raw-seq escape hatch; the + // writer must land the sequence verbatim and stay silent headless. + var buf bytes.Buffer + w := &rawSeq{seq: "SEQ"} + w.SetStdin(nil) + w.SetStderr(io.Discard) + w.SetStdout(&buf) + if err := w.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + if buf.String() != "SEQ" { + t.Errorf("Run wrote %q, want %q", buf.String(), "SEQ") + } + + w.SetStdout(nil) + if err := w.Run(); err != nil { + t.Errorf("headless Run = %v, want nil", err) + } +} + +func TestHandleEventWiresAttention(t *testing.T) { + // Both terminal states must produce the attention cmd (non-nil even + // with bell+notify off: the silent title always rides along). + m := newTestModel() + m.model = "deepseek-v4" + if _, cmd := m.handleEvent(client.Event{Type: "approval_request", ID: "a1", + Risk: "shell_exec", Command: "rm -rf x"}); cmd == nil { + t.Error("approval_request produced no cmd") + } + + m2 := newTestModel() + m2.model = "deepseek-v4" + if _, cmd := m2.handleEvent(client.Event{Type: "done"}); cmd == nil { + t.Error("done produced no cmd") + } +} diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index bc10e35..02b9922 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -13,21 +13,22 @@ import ( // copies are refused with a note rather than silently truncated. const osc52Cap = 100_000 -// clipboardWrite is a tea.ExecCommand that writes a raw escape sequence to +// rawSeq is a tea.ExecCommand that writes a raw escape sequence to // the terminal. tea.Println can't carry it: bodek runs on the alt-screen, // where the renderer drops printed lines entirely. Exec briefly pauses the // renderer and hands over the real terminal writer, so the sequence lands -// verbatim and frames can't interleave. -type clipboardWrite struct { +// verbatim and frames can't interleave. Shared by the OSC 52 clipboard +// write and the attention layer (bell / window title / OSC 9 notify). +type rawSeq struct { seq string w io.Writer } -func (c *clipboardWrite) SetStdin(io.Reader) {} -func (c *clipboardWrite) SetStderr(io.Writer) {} -func (c *clipboardWrite) SetStdout(w io.Writer) { c.w = w } +func (c *rawSeq) SetStdin(io.Reader) {} +func (c *rawSeq) SetStderr(io.Writer) {} +func (c *rawSeq) SetStdout(w io.Writer) { c.w = w } -func (c *clipboardWrite) Run() error { +func (c *rawSeq) Run() error { if c.w == nil { return nil // no terminal writer (tests, headless contexts) } @@ -59,5 +60,5 @@ func (m *Model) copyLastReply() tea.Cmd { return m.transientNoteCmd(fmt.Sprintf("reply too large for OSC 52 (%d bytes) — select it manually", len(text))) } note := m.transientNoteCmd(fmt.Sprintf("copied %d chars via OSC 52 — needs a supporting terminal", len(text))) - return tea.Batch(tea.Exec(&clipboardWrite{seq: ansi.SetSystemClipboard(text)}, nil), note) + return tea.Batch(tea.Exec(&rawSeq{seq: ansi.SetSystemClipboard(text)}, nil), note) } diff --git a/internal/tui/clipboard_test.go b/internal/tui/clipboard_test.go index e23f3be..607d33e 100644 --- a/internal/tui/clipboard_test.go +++ b/internal/tui/clipboard_test.go @@ -24,7 +24,7 @@ func TestOsc52Sequence(t *testing.T) { func TestClipboardWriteRun(t *testing.T) { var buf bytes.Buffer - c := &clipboardWrite{seq: "SEQ"} + c := &rawSeq{seq: "SEQ"} c.SetStdin(nil) c.SetStdout(&buf) c.SetStderr(io.Discard) @@ -73,7 +73,7 @@ type errWriter struct{} func (errWriter) Write(p []byte) (int, error) { return 0, errors.New("boom") } func TestClipboardWriteRunner(t *testing.T) { - c := &clipboardWrite{seq: "\x1b]52;c;aGk=\x07"} + c := &rawSeq{seq: "\x1b]52;c;aGk=\x07"} // Headless: no writer wired — the setters are no-ops and Run is silent. c.SetStdin(nil) diff --git a/internal/tui/events.go b/internal/tui/events.go index 5495d3b..fe9bac4 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -31,7 +31,8 @@ const alertTTL = 10 * time.Second type noticeExpireMsg struct{} func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { - stream := false // high-frequency event: coalesce the render (see queueRender) + stream := false // high-frequency event: coalesce the render (see queueRender) + var attn tea.Cmd // terminal attention (title/bell/notify); joined into the return batch switch ev.Type { case "session": prevSession := m.sessionID @@ -169,6 +170,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.runCtxCum = 0 // run over — the next run's cumulative restarts at zero m.lastLatency = ev.Latency m.relayout() // the busy status line releases its row + attn = m.attentionCmd(m.attentionFor(attentionDone)) case "usage": // Per-iteration report from odek serve: keeps the header gauge live @@ -261,6 +263,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.approvals = append(m.approvals, ev) m.status = "approval required" m.relayout() // the panel is taller than the textarea — shrink the viewport + attn = m.attentionCmd(m.attentionFor(attentionApproval)) case "skill_event": if ev.SubType == "suggested" { @@ -334,7 +337,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.refresh() // A turn that just ended (done / error) drains the next queued prompt. - return m, tea.Batch(listen(m.events), m.noticeSweep(), m.sendQueued(), m.planFollowup()) + return m, tea.Batch(listen(m.events), m.noticeSweep(), m.sendQueued(), m.planFollowup(), attn) } // stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in diff --git a/internal/tui/input.go b/internal/tui/input.go index 0ad5962..8571e51 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -177,12 +177,17 @@ func (m *Model) sendPrompt(text string) tea.Cmd { m.attachments = nil m.pendModel = "" // applied cl := m.cl - return func() tea.Msg { + send := func() tea.Msg { if err := cl.SendPrompt(text, opts); err != nil { return errMsg{err} } return nil } + if m.plain { + // Linear mode: the prompt joins the scrollback log above the chrome. + return tea.Batch(send, tea.Println(plainPromptLine(text))) + } + return send } // sendQueued pops the oldest queued prompt and sends it when the model is diff --git a/internal/tui/model.go b/internal/tui/model.go index 37077cf..9362cf2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -99,6 +99,16 @@ type Options struct { OdekVersion string // engine version for the header (empty when attached/unknown) Version string // bodek's own version; drives the startup update check + // Attention controls (see attention.go): Bell rings the terminal bell + // when a turn completes or an approval waits (--bel=false mutes); + // Notify raises desktop notifications via OSC 9 (--notify). + Bell bool + Notify bool + + // Plain selects the linear rendering mode: no alt-screen, append-only + // scrollback transcript, severity prefixes instead of color (--plain). + Plain bool + // Reconnect, when set, redials the server after the socket drops. The // session resumes transparently: every prompt already carries // session_id + auth_token, so the next send re-binds it server-side. @@ -112,6 +122,9 @@ type Model struct { opts Options th theme tokens *tokens.Store + bell bool // terminal bell on done / approval (--bel) + notify bool // OSC 9 desktop notifications (--notify) + plain bool // linear mode: scrollback transcript, minimal chrome (--plain) width, height int ready bool @@ -299,6 +312,9 @@ func New(cl *client.Client, opts Options) *Model { status: "ready", odekVersion: opts.OdekVersion, bodekVersion: opts.Version, + bell: opts.Bell, + notify: opts.Notify, + plain: opts.Plain, } } @@ -526,7 +542,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.noticeSweep() case eventMsg: - return m.handleEvent(client.Event(msg)) + ev := client.Event(msg) + mm, cmd := m.handleEvent(ev) + if pm := mm.(*Model); pm.plain { + cmd = tea.Batch(cmd, pm.plainPrintCmd(ev)) + } + return mm, cmd case reconnectMsg: return m.handleReconnect(msg) diff --git a/internal/tui/plain.go b/internal/tui/plain.go new file mode 100644 index 0000000..41f2263 --- /dev/null +++ b/internal/tui/plain.go @@ -0,0 +1,137 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Linear mode (--plain) replaces the alt-screen transcript with an +// append-only scrollback log: agent events print above a minimal input +// chrome while they arrive, and severity rides text prefixes so it survives +// without color. Screen readers, tmux copy-mode, and +// `bodek --plain < task > run.log` pipelines all read the same linear feed. + +// plainPanelMax caps overlay height (management drawer, palette, approval +// panel) in linear mode: they render as bottom chrome, not full screen. +const plainPanelMax = 14 + +// plainPrintCmd renders one agent event into the scrollback. Nil when plain +// mode is off or the event maps to no output (streamed fragments never +// print; the reply lands whole on done). +func (m *Model) plainPrintCmd(ev client.Event) tea.Cmd { + if !m.plain { + return nil + } + lines := m.plainEventLines(ev) + if len(lines) == 0 { + return nil + } + return tea.Println(strings.Join(lines, "\n")) +} + +// plainEventLines maps a wire event to its linear text lines. Kept free of +// tea types so tests assert the mapping directly. Every wire-borne string +// goes through collapse() — sanitize + whitespace flatten — because these +// lines land verbatim on the terminal. +func (m *Model) plainEventLines(ev client.Event) []string { + switch ev.Type { + case "thinking": + if s := collapse(ev.Content); s != "" { + return []string{"[think] " + plainClip(s)} + } + + case "tool_call": + name := collapse(ev.Name) + if arg := collapse(ev.Data); arg != "" { + return []string{plainClip("▸ " + name + " · " + arg)} + } + return []string{"▸ " + name} + + case "tool_result": + glyph := "✓" + if looksLikeError(ev.Data) { + glyph = "✗" + } + return []string{"▪ " + collapse(ev.Name) + " " + glyph} + + case "error": + if s := collapse(ev.Message); s != "" { + return []string{"[error] " + plainClip(s)} + } + + case "approval_request": + what := collapse(ev.Risk) + if cmd := collapse(ev.Command); cmd != "" { + if what != "" { + what += ": " + } + what += cmd + } + if what != "" { + what = " · " + what + } + return []string{plainClip("⚠ approval" + what + " — ↑/↓ then ⏎ (Esc denies)")} + + case "skill_event": + return []string{"· skill · " + strings.TrimSpace(collapse(ev.SubType+" "+ev.SkillName)) + eventTail(ev)} + case "memory_event": + return []string{"· memory · " + strings.TrimSpace(collapse(ev.SubType+" "+ev.Target)) + eventTail(ev)} + case "agent_signal": + return []string{"· signal · " + strings.TrimSpace(collapse(ev.SubType+" "+ev.Detail)) + eventTail(ev)} + case "subagent_log": + line := strings.TrimSpace(collapse(ev.SubType + " " + ev.Name)) + if d := collapse(ev.Detail); d != "" { + line = strings.TrimSpace(line + " · " + d) + } + return []string{plainClip("· subagent · " + line + eventTail(ev))} + + case "done": + var lines []string + if reply := m.lastReply(); reply != "" { + lines = append(lines, reply) + } + lines = append(lines, m.plainDoneSummary()) + return lines + + case client.EventDisconnected: + return []string{"[error] connection lost"} + } + return nil +} + +// plainDoneSummary builds the turn-boundary line from the telemetry the +// done handler captured (plainPrintCmd runs after handleEvent, so the last +// turnStats entry is this turn's). +func (m *Model) plainDoneSummary() string { + s := "✓ done" + if n := len(m.turnStats); n > 0 { + ts := m.turnStats[n-1] + s += fmt.Sprintf(" · %d tools · %.1fs · %d tok", ts.toolCount, ts.wall.Seconds(), ts.outTok) + } + return s +} + +// plainClip bounds a line for the scrollback log: long tool arguments and +// errors excerpt instead of flooding the feed (the full text still lives in +// the session for the TUI and exports). +func plainClip(s string) string { + const max = 160 + r := []byte(s) + if len(r) <= max { + return s + } + cut := max + for cut > 0 && r[cut-1] >= 0x80 && r[cut-1] < 0xC0 { + cut-- // never split a UTF-8 sequence + } + return string(r[:cut]) + "…" +} + +// plainPromptLine renders a submitted prompt for the scrollback. +func plainPromptLine(text string) string { + return "❯ " + plainClip(collapse(text)) +} diff --git a/internal/tui/plain_test.go b/internal/tui/plain_test.go new file mode 100644 index 0000000..d2e4eaa --- /dev/null +++ b/internal/tui/plain_test.go @@ -0,0 +1,172 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// --plain replaces the alt-screen transcript with an append-only scrollback +// log: agent events print as linear text above a minimal input chrome while +// events stream in. Severity rides text prefixes (never color alone), and +// the mode is the honest surface for screen readers and `bodek --plain +// < task > run.log` pipelines. + +func TestPlainEventLines(t *testing.T) { + m := newTestModel() + m.model = "deepseek-v4" + + tests := []struct { + name string + ev client.Event + want string // exact single line, or substring when suffixed with … + }{ + {"tool_call", client.Event{Type: "tool_call", Name: "shell", + Data: `{"command":"ls -la"}`}, `▸ shell · {"command":"ls -la"}`}, + {"tool_result ok", client.Event{Type: "tool_result", Name: "shell", + Data: "main.go\nREADME.md"}, "▪ shell ✓"}, + {"tool_result fail", client.Event{Type: "tool_result", Name: "shell", + Data: "Error: exit status 1"}, "▪ shell ✗"}, + {"thinking", client.Event{Type: "thinking", + Content: "checking the files"}, "[think] checking the files"}, + {"error", client.Event{Type: "error", Message: "boom"}, "[error] boom"}, + {"skill note", client.Event{Type: "skill_event", SubType: "loaded", + SkillName: "go"}, "· skill · loaded go"}, + } + for _, tc := range tests { + got := m.plainEventLines(tc.ev) + if len(got) != 1 { + t.Errorf("%s: got %d lines (%v), want 1", tc.name, len(got), got) + continue + } + if tc.ev.Type == "tool_result" && !strings.Contains(got[0], tc.want) { + t.Errorf("%s: line %q missing %q", tc.name, got[0], tc.want) + continue + } + if got[0] != tc.want { + t.Errorf("%s: line = %q, want %q", tc.name, got[0], tc.want) + } + } +} + +func TestPlainEventLinesApproval(t *testing.T) { + m := newTestModel() + got := m.plainEventLines(client.Event{Type: "approval_request", + Risk: "shell_exec", Command: "rm -rf x"}) + if len(got) != 1 { + t.Fatalf("got %d lines, want 1", len(got)) + } + for _, want := range []string{"⚠ approval", "shell_exec", "rm -rf x", "Esc"} { + if !strings.Contains(got[0], want) { + t.Errorf("approval line %q missing %q", got[0], want) + } + } +} + +func TestPlainEventLinesSuppressed(t *testing.T) { + // Streaming fragments and telemetry never print: the reply lands whole + // on done, and per-token lines would bury the log. + m := newTestModel() + for _, ev := range []client.Event{ + {Type: "token", Content: "hel"}, + {Type: "token_delta", Content: "lo"}, + {Type: "thinking_delta", Content: "hm"}, + {Type: "usage", ContextTokens: 99}, + } { + if got := m.plainEventLines(ev); got != nil { + t.Errorf("%s: printed %v, want nothing", ev.Type, got) + } + } +} + +func TestPlainDoneLines(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleUser, content: "q"}, + message{role: roleAsst, content: "The answer."}) + m.turnStats = append(m.turnStats, turnStats{toolCount: 2, outTok: 42, wall: 1500 * 1e6}) + + got := m.plainEventLines(client.Event{Type: "done"}) + if len(got) < 2 { + t.Fatalf("got %d lines (%v), want reply + summary", len(got), got) + } + if got[0] != "The answer." { + t.Errorf("reply line = %q, want the finalized reply", got[0]) + } + for _, want := range []string{"✓ done", "2 tools", "42 tok"} { + if !strings.Contains(got[len(got)-1], want) { + t.Errorf("summary %q missing %q", got[len(got)-1], want) + } + } +} + +func TestPlainClip(t *testing.T) { + long := strings.Repeat("x", 500) + if got := plainClip(long); len(got) > 200 || !strings.HasSuffix(got, "…") { + t.Errorf("plainClip kept %d chars, want a bounded prefix ending in …", len(got)) + } + if got := plainClip("short"); got != "short" { + t.Errorf("plainClip(short) = %q", got) + } +} + +func TestPlainPromptLine(t *testing.T) { + if got := plainPromptLine("hi\nthere"); got != "❯ hi there" { + t.Errorf("promptLine = %q, want the collapsed single line", got) + } +} + +func TestPlainCmdGating(t *testing.T) { + m := newTestModel() + ev := client.Event{Type: "tool_call", Name: "shell", Data: "ls"} + + if cmd := m.plainPrintCmd(ev); cmd != nil { + t.Error("plain mode off: expected nil cmd") + } + m.plain = true + if cmd := m.plainPrintCmd(ev); cmd == nil { + t.Error("plain mode on: expected a print cmd") + } + if cmd := m.plainPrintCmd(client.Event{Type: "token", Content: "x"}); cmd != nil { + t.Error("suppressed event still produced a cmd") + } +} + +func TestPlainViewIsChromeOnly(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleUser, content: "q"}, + message{role: roleAsst, content: "secret-reply-body"}) + m.curIdx = 1 + m.resize(100, 30) + m.refresh() + + tui := m.View() + if !strings.Contains(tui, "secret-reply-body") { + t.Fatal("TUI view lost the transcript — fixture broken") + } + + m.plain = true + plain := m.View() + if strings.Contains(plain, "secret-reply-body") { + t.Error("plain view renders transcript content; it must live in scrollback") + } + if strings.Contains(plain, "bodek") && strings.Contains(plain, "⬡") { + t.Error("plain view renders the full header; linear mode wants minimal chrome") + } + if plain == "" { + t.Error("plain view empty — input chrome vanished") + } +} + +func TestPlainViewKeepsPanel(t *testing.T) { + // Management panels stay reachable in linear mode, rendered as capped + // bottom chrome instead of taking the whole terminal. + m := newTestModel() + m.plain = true + m.panel = panelSessions + m.resize(100, 30) + if v := m.View(); v == "" { + t.Error("plain view with an open panel rendered nothing") + } +} diff --git a/internal/tui/renderers.go b/internal/tui/renderers.go index 9dbc374..9261c75 100644 --- a/internal/tui/renderers.go +++ b/internal/tui/renderers.go @@ -48,6 +48,12 @@ func diffStat(s string) (adds, dels int, ok bool) { if !diffLooksLike(s) { return 0, 0, false } + adds, dels = countDiffLines(s) + return adds, dels, adds > 0 || dels > 0 +} + +// countDiffLines tallies +/- body lines, skipping the ---/+++ file markers. +func countDiffLines(s string) (adds, dels int) { for _, ln := range strings.Split(s, "\n") { switch { case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"): @@ -58,7 +64,99 @@ func diffStat(s string) (adds, dels int, ok bool) { dels++ } } - return adds, dels, adds > 0 || dels > 0 + return adds, dels +} + +// ── fenced diff blocks ───────────────────────────────────────────────────── + +// fencedDiffBlocks extracts the contents of well-formed ` ```diff ` fences +// in s, in order. A fence is authoritative — its body is a diff even +// without a @@ hunk header — but it must close; unterminated fences are +// left to the verbatim path. +func fencedDiffBlocks(s string) []string { + var blocks []string + var cur []string + in := false + for _, ln := range strings.Split(s, "\n") { + t := strings.TrimSpace(ln) + if in { + if t == "```" { + in = false + blocks = append(blocks, strings.Join(cur, "\n")) + cur = nil + continue + } + cur = append(cur, ln) + continue + } + if t == "```diff" || strings.HasPrefix(t, "```diff ") { + in = true + cur = nil + } + } + return blocks +} + +// hasFencedDiff reports whether s embeds at least one closed ` ```diff ` +// fence. +func hasFencedDiff(s string) bool { + return len(fencedDiffBlocks(s)) > 0 +} + +// renderMixedDiff renders tool output that interleaves prose with fenced +// ` ```diff ` blocks: prose keeps the verbatim style, each fence unwraps +// and tints through renderDiff, and the fence markers themselves never +// appear in the output. +func renderMixedDiff(s string, width int, th theme) []string { + w := detailWidth(width) + var out []string + var block []string + in := false + flush := func() { + if len(block) > 0 { + out = append(out, renderDiff(strings.Join(block, "\n"), width, th)...) + block = nil + } + } + for _, ln := range strings.Split(s, "\n") { + t := strings.TrimSpace(ln) + if in { + if t == "```" { + in = false + flush() + continue + } + block = append(block, ln) + continue + } + if t == "```diff" || strings.HasPrefix(t, "```diff ") { + in = true + block = nil + continue + } + if t == "" { + continue + } + out = append(out, th.stepRes.Render(truncate(strings.TrimRight(ln, " \t"), w))) + if len(out) >= maxDetailLines { + return append(out, th.stepArg.Render("… output truncated")) + } + } + return out +} + +// diffStatOf counts a result's diff activity across both shapes: fenced +// ` ```diff ` blocks (which win outright) and a whole-result unified diff. +func diffStatOf(s string) (adds, dels int, ok bool) { + if blocks := fencedDiffBlocks(s); len(blocks) > 0 { + for _, b := range blocks { + a, d := countDiffLines(b) + adds += a + dels += d + } + return adds, dels, adds > 0 || dels > 0 + } + return diffStat(s) } // renderDiff tints a unified diff: + green, − red, hunk headers steel, file @@ -212,6 +310,11 @@ func cutPrefixTrim(s, prefix string) (string, bool) { // returned lines are fully styled and truncated — append them verbatim. func stepDetail(name, result string, width int, th theme) []string { switch { + case hasFencedDiff(result): + // Fences first: prose stays verbatim, only the fenced content tints. + if out := renderMixedDiff(result, width, th); len(out) > 0 { + return out + } case diffLooksLike(result): if out := renderDiff(result, width, th); len(out) > 0 { return out @@ -240,7 +343,7 @@ func stepDetail(name, result string, width int, th theme) []string { // stepHeadSuffix renders the typed chip a step line gains from its result: // a diffstat for diffs, a pass/fail summary for test runs. func stepHeadSuffix(name, result string, th theme) string { - if adds, dels, ok := diffStat(result); ok { + if adds, dels, ok := diffStatOf(result); ok { return th.diffAdd.Render(fmt.Sprintf(" +%d", adds)) + th.diffDel.Render(fmt.Sprintf(" −%d", dels)) } diff --git a/internal/tui/renderers_diff_test.go b/internal/tui/renderers_diff_test.go new file mode 100644 index 0000000..8a15c9f --- /dev/null +++ b/internal/tui/renderers_diff_test.go @@ -0,0 +1,112 @@ +package tui + +import ( + "strings" + "testing" +) + +// Fenced ```diff blocks embed in tool output with prose around them (edit +// tools, agent explanations). The whole-result diff path mis-styles that +// mix — prose tints as diff lines and fence markers leak through — and a +// fence without a @@ hunk (common in model-written patches) misses the diff +// renderer entirely. The fence is authoritative: when present, only its +// content tints. + +func TestHasFencedDiff(t *testing.T) { + yes := []string{ + "Here's the patch:\n```diff\n+added\n```", + "```diff\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n+x\n```", + "text\n```diff \n+x\n```\nmore", // trailing space on the fence opener + } + for _, s := range yes { + if !hasFencedDiff(s) { + t.Errorf("hasFencedDiff(%q) = false, want true", s) + } + } + no := []string{ + "+just a plus line", + "```go\nif x {\n\t+1\n}\n```", // a non-diff fence never counts + "```diff\nnever closed", + "plain prose", + } + for _, s := range no { + if hasFencedDiff(s) { + t.Errorf("hasFencedDiff(%q) = true, want false", s) + } + } +} + +func TestFencedDiffBlocksExtracts(t *testing.T) { + s := "before\n```diff\n+one\n-two\n```\nbetween\n```diff\n+three\n```" + blocks := fencedDiffBlocks(s) + if len(blocks) != 2 { + t.Fatalf("got %d blocks (%v), want 2", len(blocks), blocks) + } + if blocks[0] != "+one\n-two" || blocks[1] != "+three" { + t.Errorf("blocks = %q, want the unwrapped fence contents", blocks) + } +} + +func TestRenderMixedDiff(t *testing.T) { + th := newTheme() + s := "Here's the patch:\n```diff\n@@ -1 +1 @@\n-old line\n+new line\n```\nDone." + out := renderMixedDiff(s, 100, th) + joined := strings.Join(out, "\n") + + if strings.Contains(joined, "```") { + t.Errorf("fence markers leaked into the render:\n%s", joined) + } + for _, want := range []string{"Here's the patch:", "@@ -1 +1 @@", "+new line", "-old line", "Done."} { + if !strings.Contains(joined, want) { + t.Errorf("render missing %q in:\n%s", want, joined) + } + } + // The prose must stay verbatim-styled, not tinted as diff lines: the + // diff tint colors carry the +/- semantics, prose keeps the plain style. + for i, ln := range out { + if strings.Contains(ln, "Here's the patch:") && ln != th.stepRes.Render(truncate("Here's the patch:", detailWidth(100))) { + t.Errorf("prose line %d picked up diff styling: %q", i, ln) + } + } +} + +func TestRenderMixedDiffFenceWithoutHunks(t *testing.T) { + // No @@ anywhere: diffLooksLike is false, but the fence is authoritative. + th := newTheme() + out := renderMixedDiff("```diff\n+added\n-removed\n```", 100, th) + joined := strings.Join(out, "\n") + for _, want := range []string{"+added", "-removed"} { + if !strings.Contains(joined, want) { + t.Errorf("render missing %q in:\n%s", want, joined) + } + } + if !strings.Contains(joined, th.diffAdd.Render("+added")) { + t.Error("fenced + line did not get the add tint") + } + if !strings.Contains(joined, th.diffDel.Render("-removed")) { + t.Error("fenced - line did not get the del tint") + } +} + +func TestStepDetailPrefersFences(t *testing.T) { + th := newTheme() + s := "Patch:\n```diff\n+new line\n```\nEnd." + out := stepDetail("edit", s, 100, th) + joined := strings.Join(out, "\n") + if !strings.Contains(joined, "+new line") || strings.Contains(joined, "```") { + t.Errorf("fenced block not rendered as a diff:\n%s", joined) + } + // The whole-result diff path keeps working (regression guard). + whole := stepDetail("edit", "@@ -1 +1 @@\n-old\n+new", 100, th) + if !strings.Contains(strings.Join(whole, "\n"), "+new") { + t.Errorf("whole-result diff regressed:\n%s", strings.Join(whole, "\n")) + } +} + +func TestStepHeadSuffixCountsFences(t *testing.T) { + th := newTheme() + suffix := stepHeadSuffix("edit", "```diff\n+a\n-b\n-c\n```", th) + if !strings.Contains(suffix, "+1") || !strings.Contains(suffix, "−2") { + t.Errorf("fence diffstat chip = %q, want +1 −2", suffix) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 68ee7b9..5877f9c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -15,6 +15,9 @@ func (m *Model) View() string { if !m.ready { return "\n starting bodek…" } + if m.plain { + return m.plainView() + } body := m.vp.View() if m.panel != panelNone { body = m.renderPanel(m.width, m.vp.Height) @@ -29,6 +32,23 @@ func (m *Model) View() string { return strings.Join(parts, "\n") } +// plainView composes linear mode's bottom chrome: status line, capped +// panel/popover, input, footer. The transcript itself lives in the +// terminal's scrollback, printed line-by-line as events arrive (plain.go). +func (m *Model) plainView() string { + var parts []string + if sl := m.statusLine(); sl != "" { + parts = append(parts, sl) + } + if m.panel != panelNone { + parts = append(parts, m.renderPanel(m.width, plainPanelMax)) + } else if m.popover { + parts = append(parts, m.popoverView(m.width, plainPanelMax)) + } + parts = append(parts, m.inputArea(), m.footer()) + return strings.Join(parts, "\n") +} + // ── header ───────────────────────────────────────────────────────────────── func (m *Model) header() string {