5 Commits

Author SHA1 Message Date
cadd4c8f64 Release v0.0.6
All checks were successful
release / build-linux-amd64 (push) Successful in 11m48s
2026-05-15 21:55:09 +01:00
98d1c059cf summarizer tweaks 2026-05-15 21:54:14 +01:00
cf65d5d707 Wrap toast bodies, slim the dismiss hint, and stop flicker
Toasts now render three content rows with word-wrapped bodies. The
in-toast "Ctrl-N · N more" hint is replaced by a short
"Ctrl-N · dismiss" entry on the status strip that only appears
while a notification is live.

The box stops flickering while the focused child repaints its TUI:
the overlay is stitched onto the per-chunk PTY write under outMu
and bracketed by DECSET 2026 so supporting terminals buffer the
child's redraw and the box paint into a single frame.
2026-05-15 21:24:18 +01:00
ef9b8e71c6 Release v0.0.5
All checks were successful
release / build-linux-amd64 (push) Successful in 11m51s
2026-05-15 20:56:38 +01:00
e64060e40f Calm down the focused-section labels in the command palette
Focused-section rows are now bare verbs (Rename, Close, Stop, Restart,
Delete, Edit) instead of repeating the focused name. The title bar
already carries the subject, and the row hint preserves fuzzy-search
matches like "close codex". Section banners are replaced by a single
blank spacer row so the verbs themselves carry the visual weight,
and the Open section no longer lists "Switch to <current>" for the
pane that's already focused.
2026-05-15 20:30:31 +01:00
9 changed files with 326 additions and 125 deletions

View File

@@ -6,6 +6,30 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [0.0.6] - 2026-05-15
### Changed
- Toast notifications now reserve three content rows and word-wrap
the message body inside the box, replacing the previous
single-line+ellipsis layout. The `Ctrl-N · N more` inline hint is
gone; instead the host status strip surfaces a `Ctrl-N · dismiss`
hint, shown only while a notification is on screen so the chord
doesn't advertise itself when it has nothing to dismiss.
### Fixed
- Auto-summary no longer fails immediately with `codex summarizer:
error: unexpected argument '--ask-for-approval' found`. The codex
CLI dropped that flag; we now rely on `--sandbox read-only` (which
already implies no approvals) instead of passing it.
- Toast box no longer flickers / half-erases while the focused
child (claude, codex, opencode, etc.) repaints its TUI. The
overlay is now stitched onto the end of the per-chunk PTY write
under `outMu`, and wrapped in DECSET 2026 (synchronized output)
brackets so terminals that support it batch the child's redraw +
the box paint into a single frame instead of racing cell-by-cell.
## [0.0.5] - 2026-05-15
### Changed ### Changed
- Replaced the single-slot status-line "flash" with a stackable toast - Replaced the single-slot status-line "flash" with a stackable toast
surface anchored at the top-right of the focused pane. `flashError`, surface anchored at the top-right of the focused pane. `flashError`,
@@ -17,6 +41,18 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `Ctrl-N` is consumed by the host only when there is a toast to - `Ctrl-N` is consumed by the host only when there is a toast to
dismiss; an empty stack lets `Ctrl-N` pass through to the focused dismiss; an empty stack lets `Ctrl-N` pass through to the focused
child so readline / nano / emacs / opencode keep their bindings. child so readline / nano / emacs / opencode keep their bindings.
- Command palette is calmer when something is focused. Focused-section
rows now read as bare verbs (`Rename`, `Close`, `Stop`, `Restart`,
`Delete`, `Edit`) instead of repeating the focused name (`Close
agent: codex`); the title bar's `on: codex` / `pad: notes.md`
carries the subject. Fuzzy queries still match the dropped context
through the row hint (e.g. typing `close codex` still finds the
Close row).
- Dashed `── Focused ──` / `── Open ──` / `── Spawn ──` section
banners are gone. Sections are separated by a single blank spacer
row, so the action labels themselves carry the visual weight.
- The Open section no longer lists a `Switch to <current>` row for
the pane you're already focused on.
## [0.0.4] - 2026-05-15 ## [0.0.4] - 2026-05-15

View File

@@ -1,3 +0,0 @@
The close action in the command palette should just be "Close current agent" rather than "Close codex"
Same with the other "focused" parts. It seems a bit clunky right now. "Close current agent"
In general I think while the feature set has grown, the actual refinement of it isn't great, it feels a bit cluttered.

View File

@@ -187,9 +187,7 @@ func Run(ctx context.Context, opts Options) error {
}, func(_ string, result summaryState) { }, func(_ string, result summaryState) {
if result.Error != "" { if result.Error != "" {
st.flashError(fmt.Sprintf("summary: %v", result.Error)) st.flashError(fmt.Sprintf("summary: %v", result.Error))
return
} }
st.flashTransient("summary updated")
}) })
sess.SetMetrics(metrics) sess.SetMetrics(metrics)
host.attention = st host.attention = st
@@ -969,14 +967,19 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.metrics.recordRender(time.Since(rstart)) st.metrics.recordRender(time.Since(rstart))
} }
} }
// One write covers the autowrap-disable prelude, the chunk, and the // One write covers the autowrap-disable prelude, the chunk, the
// autowrap-restore postlude — three syscalls collapsed into one // autowrap-restore postlude, and (when a toast is up) the toast
// under outMu. The three sequences were already emitted atomically // overlay — four syscalls collapsed into one under outMu. The
// under the lock; coalescing just halves the syscall count. // sequences were already emitted atomically under the lock;
wrapped := make([]byte, 0, len(out)+10) // coalescing just halves the syscall count and makes claude's
// continuous redraws + our toast layer land in the same frame so
// the box doesn't flicker as the child paints over its cells.
overlay := st.toastOverlayBytes()
wrapped := make([]byte, 0, len(out)+len(overlay)+10)
wrapped = append(wrapped, "\x1b[?7l"...) wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...) wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...) wrapped = append(wrapped, "\x1b[?7h"...)
wrapped = append(wrapped, overlay...)
var wstart time.Time var wstart time.Time
if st.metrics != nil { if st.metrics != nil {
wstart = time.Now() wstart = time.Now()
@@ -1219,6 +1222,12 @@ func (st *uiState) drawStatusLine() {
hints = append(hints, "Ctrl-R · restart") hints = append(hints, "Ctrl-R · restart")
} }
} }
// Surface the toast-dismiss chord only while a notification is on
// screen — the hint is noise otherwise, and Ctrl-N falls through
// to the focused PTY when the stack is empty.
if st.toasts.length() > 0 {
hints = append(hints, "Ctrl-N · dismiss")
}
right := strings.Join(hints, " · ") right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 { for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:] hints = hints[1:]

View File

@@ -54,14 +54,6 @@ const (
groupQuit groupQuit
) )
var groupLabels = map[int]string{
groupFocused: "Focused",
groupOpen: "Open",
groupSpawn: "Spawn",
groupSettings: "Settings",
groupQuit: "Quit",
}
type paletteItem struct { type paletteItem struct {
label string label string
hint string hint string
@@ -205,8 +197,10 @@ func (p *paletteState) rebuild() {
all := p.buildItems(macro) all := p.buildItems(macro)
if rest == "" { if rest == "" {
// No textual filter: render with section headers between groups. // No textual filter: render with blank spacer rows between
p.items = itemsWithHeaders(all) // groups so sections read as scannable bands without dashed
// headers stealing visual weight.
p.items = itemsWithSpacers(all)
p.clampCursor() p.clampCursor()
return return
} }
@@ -243,25 +237,28 @@ func (p *paletteState) rebuild() {
} }
// buildItems assembles every selectable row in fixed group order // buildItems assembles every selectable row in fixed group order
// (Focused → Open → Spawn → Quit). Headers are added by // (Focused → Open → Spawn → Quit). Blank spacer rows are added by
// itemsWithHeaders for the no-query case; scored mode drops them. // itemsWithSpacers for the no-query case; scored mode drops them.
// When macro is non-empty the result is filtered down to the kinds // When macro is non-empty the result is filtered down to the kinds
// that macro retains. // that macro retains.
func (p *paletteState) buildItems(macro string) []paletteItem { func (p *paletteState) buildItems(macro string) []paletteItem {
var out []paletteItem var out []paletteItem
// Group 0: Focused — context-aware actions for whatever owns focus. // Group 0: Focused — context-aware actions for whatever owns focus.
// A focused scratchpad shadows any focused child. // A focused scratchpad shadows any focused child. Labels are bare
// verbs because the title bar already carries the subject ("on:
// codex" / "pad: notes.md"); the noun + name move into the hint so
// fuzzy queries like "close codex" still surface the row.
switch { switch {
case p.focusedPad != "": case p.focusedPad != "":
name := p.focusedPad name := p.focusedPad
out = append(out, out = append(out,
paletteItem{label: "Delete scratchpad: " + name, hint: "remove the file from disk", paletteItem{label: "Edit", hint: "edit scratchpad · " + name + " (opens $EDITOR)",
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
paletteItem{label: "Rename scratchpad: " + name, hint: "inline rename · enter to commit",
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Edit scratchpad: " + name, hint: "open in external editor (zed)",
action: paletteAction{kind: "pad-edit", padName: name}, group: groupFocused}, action: paletteAction{kind: "pad-edit", padName: name}, group: groupFocused},
paletteItem{label: "Rename", hint: "rename scratchpad · " + name,
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete scratchpad · " + name,
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
) )
case p.focused != "": case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil { if c := findChildByID(p.children, p.focused); c != nil {
@@ -269,40 +266,39 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
switch c.Kind { switch c.Kind {
case KindAgent: case KindAgent:
out = append(out, out = append(out,
paletteItem{label: "Rename agent: " + name, hint: "inline rename · enter to commit", paletteItem{label: "Rename", hint: "rename agent · " + name,
action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close agent: " + name, hint: "SIGTERM " + strings.Join(c.Argv, " "), paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)",
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
) )
default: default:
out = append(out, out = append(out,
paletteItem{label: "Rename process: " + name, hint: "inline rename · enter to commit", paletteItem{label: "Rename", hint: "rename process · " + name,
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete process: " + name, hint: "remove entry; SIGKILL if alive", paletteItem{label: "Stop", hint: "stop process · " + name + " (SIGTERM, keeps entry)",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
paletteItem{label: "Stop process: " + name, hint: "SIGTERM · keep entry for restart",
action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart process: " + name, hint: "SIGTERM then start with same argv", paletteItem{label: "Restart", hint: "restart process · " + name,
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete process · " + name + " (SIGKILL if alive)",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
) )
} }
} }
} }
// Group 1: Open — switch entries for every running child. Dead // Group 1: Open — switch entries for every running child *other than*
// the one already focused (no point offering a no-op switch). Dead
// agents are filtered out (no restart path); dead command processes // agents are filtered out (no restart path); dead command processes
// remain so they can be restarted. The currently-focused child is // remain so they can be restarted.
// marked with a leading ▶ instead of the older "• … (current)" suffix
// so the row reads cleaner.
for _, c := range p.children { for _, c := range p.children {
if c.ID == p.focused {
continue
}
if c.Kind == KindAgent && c.Status() != StatusRunning { if c.Kind == KindAgent && c.Status() != StatusRunning {
continue continue
} }
label := "Switch to " + c.DisplayName() label := "Switch to " + c.DisplayName()
hint := strings.Join(c.Argv, " ") hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "▶ " + label
}
if c.Status() != StatusRunning { if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]" label = label + " [" + string(c.Status()) + "]"
} }
@@ -384,9 +380,11 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
return out return out
} }
// itemsWithHeaders splices a non-selectable header row in front of // itemsWithSpacers splices a non-selectable blank row between groups
// each new group so the (unfiltered) list reads as scannable bands. // so the (unfiltered) list reads as scannable bands without dashed
func itemsWithHeaders(items []paletteItem) []paletteItem { // section headers stealing weight from the actions themselves. The
// first group never gets a leading spacer.
func itemsWithSpacers(items []paletteItem) []paletteItem {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
@@ -394,17 +392,14 @@ func itemsWithHeaders(items []paletteItem) []paletteItem {
currentGroup := -1 currentGroup := -1
for _, it := range items { for _, it := range items {
if it.group != currentGroup { if it.group != currentGroup {
currentGroup = it.group if currentGroup != -1 {
label, ok := groupLabels[it.group]
if !ok {
label = ""
}
result = append(result, paletteItem{ result = append(result, paletteItem{
label: "── " + label + " ──",
action: paletteAction{kind: "header"}, action: paletteAction{kind: "header"},
group: it.group, group: it.group,
}) })
} }
currentGroup = it.group
}
result = append(result, it) result = append(result, it)
} }
return result return result

View File

@@ -31,16 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
func TestContextItemsScratchpad(t *testing.T) { func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{}) p := newPalette(nil, "", "notes.md", preset.Set{})
// pad-delete is the first selectable row; the Focused section header // With the dashed section header gone, pad-edit is the first row;
// (a non-selectable row) sits above it. // pad-rename-form follows, with destructive pad-delete last in the
if i, _ := findItem(p, "pad-delete"); i != 1 { // Focused section.
t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i) if i, _ := findItem(p, "pad-edit"); i != 0 {
t.Fatalf("pad-edit at %d; want 0", i)
} }
if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" { if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" {
t.Fatalf("pad-rename-form missing or wrong padName: %+v", it) t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)
} }
if _, it := findItem(p, "pad-edit"); it == nil { if i, _ := findItem(p, "pad-delete"); i < 0 {
t.Fatalf("pad-edit missing") t.Fatalf("pad-delete missing")
} }
// No focused child → no agent/proc context items. // No focused child → no agent/proc context items.
if i, _ := findItem(p, "agent-rename-form"); i != -1 { if i, _ := findItem(p, "agent-rename-form"); i != -1 {
@@ -83,8 +84,11 @@ func TestContextItemsProcess(t *testing.T) {
} }
func TestContextItemsAppearAboveSwitch(t *testing.T) { func TestContextItemsAppearAboveSwitch(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand) // Two children so there's still a non-focused switch entry to compare
p := newPalette([]*Child{c}, "pid", "", preset.Set{}) // against (the focused child is suppressed from the Open section).
focused := makeFakeChild("pid", "devserver", KindCommand)
other := makeFakeChild("oid", "worker", KindCommand)
p := newPalette([]*Child{focused, other}, "pid", "", preset.Set{})
procIdx, _ := findItem(p, "proc-rename-form") procIdx, _ := findItem(p, "proc-rename-form")
switchIdx, _ := findItem(p, "switch") switchIdx, _ := findItem(p, "switch")
if procIdx < 0 || switchIdx < 0 { if procIdx < 0 || switchIdx < 0 {

View File

@@ -57,21 +57,44 @@ func TestPaletteDropsGlobalCloseList(t *testing.T) {
// -- Phase 2: section headers and cursor skip ------------------------ // -- Phase 2: section headers and cursor skip ------------------------
func TestPaletteSectionHeadersPresent(t *testing.T) { func TestPaletteSectionsSeparatedBySpacers(t *testing.T) {
// Section-named dashed headers are gone; groups are visually
// separated by a single non-selectable blank row. Verify that the
// build emits one such spacer between every pair of adjacent groups
// and never a leading spacer.
c := makeFakeChild("a", "claude", KindAgent) c := makeFakeChild("a", "claude", KindAgent)
p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}}) other := makeFakeChild("b", "worker", KindCommand)
wantSections := []string{"Focused", "Open", "Spawn", "Quit"} p := newPalette([]*Child{c, other}, "a", "",
for _, w := range wantSections { preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
found := false
for _, it := range p.items { if len(p.items) == 0 {
if it.action.kind == "header" && strings.Contains(it.label, w) { t.Fatalf("palette built no items")
found = true }
break if p.items[0].action.kind == "header" {
t.Fatalf("first row is a spacer; should be a selectable item")
}
transitions := 0
prevGroup := p.items[0].group
for i := 1; i < len(p.items); i++ {
it := p.items[i]
if it.group != prevGroup {
if it.action.kind != "header" || it.label != "" {
t.Fatalf("group transition at %d not a blank spacer: %+v", i, it)
}
transitions++
// The row immediately after the spacer must be selectable.
if i+1 >= len(p.items) || p.items[i+1].action.kind == "header" {
t.Fatalf("spacer at %d not followed by selectable row", i)
}
prevGroup = p.items[i+1].group
}
// No dashed banners anywhere.
if it.action.kind == "header" && strings.Contains(it.label, "──") {
t.Errorf("dashed section header still present at %d: %q", i, it.label)
} }
} }
if !found { if transitions == 0 {
t.Errorf("section header %q missing from items", w) t.Fatalf("no section transitions found in palette items")
}
} }
} }

View File

@@ -404,7 +404,7 @@ func runSummarizerCommand(ctx context.Context, cfg autoSummarySettings, projectD
case "claude": case "claude":
cmd = exec.CommandContext(ctx, "claude", "--print", "--model", model, prompt) cmd = exec.CommandContext(ctx, "claude", "--print", "--model", model, prompt)
default: default:
cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--ask-for-approval", "never", "--model", model, "-") cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--model", model, "-")
cmd.Stdin = strings.NewReader(prompt) cmd.Stdin = strings.NewReader(prompt)
} }
cmd.Dir = projectDir cmd.Dir = projectDir

View File

@@ -1,7 +1,6 @@
package app package app
import ( import (
"fmt"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -40,6 +39,11 @@ const toastBoxMaxWidth = 50
// any narrower and there's not enough room for borders + content. // any narrower and there's not enough room for borders + content.
const toastBoxMinWidth = 20 const toastBoxMinWidth = 20
// toastContentRows is how many lines of message body each toast box
// reserves. The dismiss hint lives on the host status strip, so the
// box itself is purely the message.
const toastContentRows = 3
// toastStack owns the ordered list of live toasts. Oldest at // toastStack owns the ordered list of live toasts. Oldest at
// index 0, newest (visually topmost) at the end. The stack's own // index 0, newest (visually topmost) at the end. The stack's own
// mutex is intentionally separate from uiState.mu so push / dismiss // mutex is intentionally separate from uiState.mu so push / dismiss
@@ -115,6 +119,10 @@ func (st *uiState) notifyToast(kind toastKind, text string) {
// canvas). Each of those paths calls renderToasts at the end, so // canvas). Each of those paths calls renderToasts at the end, so
// the toast layer is always reapplied on top of a freshly-drawn // the toast layer is always reapplied on top of a freshly-drawn
// pane. Centralised so push / dismiss / clear share one code path. // pane. Centralised so push / dismiss / clear share one code path.
//
// The status strip also gains/loses the "Ctrl-N · dismiss" hint as
// the stack toggles between empty and non-empty, so we redraw it
// here too rather than waiting for the chrome ticker.
func (st *uiState) refreshToastSurface() { func (st *uiState) refreshToastSurface() {
st.mu.Lock() st.mu.Lock()
focusedPad := st.focusedPad focusedPad := st.focusedPad
@@ -134,6 +142,7 @@ func (st *uiState) refreshToastSurface() {
default: default:
st.renderEmptyState() st.renderEmptyState()
} }
st.drawStatusLine()
} }
// renderToasts draws the toast stack over the top-right of the // renderToasts draws the toast stack over the top-right of the
@@ -142,33 +151,62 @@ func (st *uiState) refreshToastSurface() {
// freshly-redrawn pane content. Safe to call when the stack is // freshly-redrawn pane content. Safe to call when the stack is
// empty (no-op). // empty (no-op).
func (st *uiState) renderToasts() { func (st *uiState) renderToasts() {
bytes := st.toastOverlayBytes()
if len(bytes) == 0 {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(bytes)
}
// toastOverlayBytes builds the toast layer as a single byte buffer
// without writing to stdout. Returns nil when the stack is empty or
// the layout can't accommodate a box. Callers either write it
// directly (renderToasts) or stitch it onto the end of another
// stdout write so claude/codex/opencode redraws that paint over the
// top-right region can't leave the toast half-erased.
func (st *uiState) toastOverlayBytes() []byte {
items := st.toasts.snapshot() items := st.toasts.snapshot()
if len(items) == 0 { if len(items) == 0 {
return return nil
} }
st.mu.Lock() st.mu.Lock()
palOpen := st.palette != nil palOpen := st.palette != nil
st.mu.Unlock() st.mu.Unlock()
if palOpen { if palOpen {
return return nil
} }
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
paneCols := int(layout.childCols()) paneCols := int(layout.childCols())
paneRows := int(layout.childRows()) paneRows := int(layout.childRows())
if paneCols < toastBoxMinWidth+2 || paneRows < 3 { if paneCols < toastBoxMinWidth+2 || paneRows < toastContentRows+2 {
return return nil
} }
boxWidth := toastBoxMaxWidth boxWidth := toastBoxMaxWidth
if max := paneCols - 4; max < boxWidth { if max := paneCols - 4; max < boxWidth {
boxWidth = max boxWidth = max
} }
if boxWidth < toastBoxMinWidth { if boxWidth < toastBoxMinWidth {
return return nil
} }
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
// Reserve two columns for the icon prefix on row 1 so wrapped rows
// indent under the body text rather than under the glyph.
const iconCols = 2
bodyRoom := contentWidth - iconCols
if bodyRoom < 1 {
return nil
}
var b strings.Builder var b strings.Builder
b.WriteString("\x1b7\x1b[?25l") // Wrap the whole overlay in DECSET 2026 (synchronized output)
// brackets so terminals that support BSU/ESU buffer the box paint
// into a single frame — without this, claude's continuous redraws
// and our overlay race on each cell, producing visible flicker.
// Terminals that don't recognise 2026 ignore the brackets, so the
// fallback behaviour is the same as before.
b.WriteString("\x1b[?2026h\x1b7\x1b[?25l")
row := int(layout.mainTop) + 1 row := int(layout.mainTop) + 1
col := int(layout.mainLeft) + paneCols - boxWidth - 1 col := int(layout.mainLeft) + paneCols - boxWidth - 1
@@ -180,20 +218,13 @@ func (st *uiState) renderToasts() {
// reverse so the most recent push lands at the smallest row. // reverse so the most recent push lands at the smallest row.
for idx := len(items) - 1; idx >= 0; idx-- { for idx := len(items) - 1; idx >= 0; idx-- {
t := items[idx] t := items[idx]
isTopmost := idx == len(items)-1 height := toastContentRows + 2
hintLine := ""
if isTopmost && len(items) > 1 {
hintLine = fmt.Sprintf("Ctrl-N · %d more", len(items)-1)
}
height := 3
if hintLine != "" {
height++
}
// Stop if we'd run off the bottom of the pane. // Stop if we'd run off the bottom of the pane.
if row+height > int(layout.mainTop)+paneRows { if row+height > int(layout.mainTop)+paneRows {
break break
} }
border := toastBorderStyle(t.kind) border := toastBorderStyle(t.kind)
wrapped := wrapToastBody(t.text, bodyRoom)
// Top border. // Top border.
moveTo(&b, row, col) moveTo(&b, row, col)
@@ -204,40 +235,22 @@ func (st *uiState) renderToasts() {
b.WriteString(styleReset) b.WriteString(styleReset)
row++ row++
// Content row. // Content rows. Row 0 carries the kind glyph; rows 1..N indent
// by iconCols spaces so wrapped text lines up under the body.
for i := 0; i < toastContentRows; i++ {
moveTo(&b, row, col) moveTo(&b, row, col)
b.WriteString(border) b.WriteString(border)
b.WriteString("│") b.WriteString("│")
b.WriteString(styleReset) b.WriteString(styleReset)
b.WriteString(" ") b.WriteString(" ")
if i == 0 {
b.WriteString(toastIcon(t.kind)) b.WriteString(toastIcon(t.kind))
body := t.text } else {
bodyRoom := contentWidth - 2 // icon + space b.WriteString(strings.Repeat(" ", iconCols))
if visibleLen(body) > bodyRoom {
body = clipRunes(body, bodyRoom-1) + "…"
} }
b.WriteString(body) line := wrapped[i]
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(body)))) b.WriteString(line)
b.WriteString(" ") b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(line))))
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
row++
// Hint row (topmost only, when stack has more than one).
if hintLine != "" {
if visibleLen(hintLine) > contentWidth {
hintLine = clipRunes(hintLine, contentWidth-1) + "…"
}
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
b.WriteString(styleHint)
b.WriteString(hintLine)
b.WriteString(styleReset)
b.WriteString(strings.Repeat(" ", max(0, contentWidth-visibleLen(hintLine))))
b.WriteString(" ") b.WriteString(" ")
b.WriteString(border) b.WriteString(border)
b.WriteString("│") b.WriteString("│")
@@ -258,11 +271,8 @@ func (st *uiState) renderToasts() {
row++ row++
} }
b.WriteString("\x1b[?25h\x1b8") b.WriteString("\x1b[?25h\x1b8\x1b[?2026l")
return []byte(b.String())
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.WriteString(b.String())
} }
func toastBorderStyle(kind toastKind) string { func toastBorderStyle(kind toastKind) string {
@@ -276,6 +286,69 @@ func toastBorderStyle(kind toastKind) string {
} }
} }
// wrapToastBody word-wraps text into exactly toastContentRows lines,
// each at most width visible runes wide. Short messages are padded
// with empty trailing lines so callers can iterate a fixed-size
// slice; messages that don't fit get ellipsized on the last line.
func wrapToastBody(text string, width int) []string {
out := make([]string, toastContentRows)
if width < 1 {
return out
}
all := wrapToastWords(text, width)
if len(all) > toastContentRows {
all = all[:toastContentRows]
last := all[len(all)-1]
if visibleLen(last) >= width {
last = clipRunes(last, width-1) + "…"
} else {
last = last + "…"
}
all[len(all)-1] = last
}
for i, l := range all {
out[i] = l
}
return out
}
// wrapToastWords is a small word-wrapper sized for toast bodies:
// greedy, breaks overlong words on rune boundaries, drops collapsing
// whitespace via strings.Fields.
func wrapToastWords(text string, width int) []string {
var lines []string
var cur string
flush := func() {
if cur != "" {
lines = append(lines, cur)
cur = ""
}
}
for _, word := range strings.Fields(text) {
for visibleLen(word) > width {
flush()
head := clipRunes(word, width)
lines = append(lines, head)
word = word[len(head):]
}
if word == "" {
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
flush()
cur = word
}
flush()
return lines
}
func toastIcon(kind toastKind) string { func toastIcon(kind toastKind) string {
switch kind { switch kind {
case toastError: case toastError:

View File

@@ -1,6 +1,9 @@
package app package app
import "testing" import (
"strings"
"testing"
)
func TestToastStackPushAndOrder(t *testing.T) { func TestToastStackPushAndOrder(t *testing.T) {
var s toastStack var s toastStack
@@ -98,3 +101,64 @@ func TestToastStackSnapshotIsCopy(t *testing.T) {
t.Fatalf("snapshot is not an independent copy: %#v", again) t.Fatalf("snapshot is not an independent copy: %#v", again)
} }
} }
func TestWrapToastBodyFixedHeight(t *testing.T) {
got := wrapToastBody("short", 20)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
if got[0] != "short" {
t.Fatalf("line 0 = %q, want \"short\"", got[0])
}
if got[1] != "" || got[2] != "" {
t.Fatalf("trailing pads not empty: %#v", got)
}
}
func TestWrapToastBodyWrapsOnWordBoundary(t *testing.T) {
got := wrapToastBody("the quick brown fox jumps over", 10)
// Expect greedy fill: "the quick" (9), "brown fox" (9), "jumps over" (10).
want := []string{"the quick", "brown fox", "jumps over"}
for i, w := range want {
if got[i] != w {
t.Fatalf("line %d = %q, want %q (full=%#v)", i, got[i], w, got)
}
}
}
func TestWrapToastBodyEllipsizesOverflow(t *testing.T) {
got := wrapToastBody("alpha beta gamma delta epsilon zeta eta theta", 6)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
last := got[toastContentRows-1]
if !strings.HasSuffix(last, "…") {
t.Fatalf("overflow should ellipsize last line, got %q (full=%#v)", last, got)
}
if visibleLen(last) > 6 {
t.Fatalf("last line %q exceeds width 6", last)
}
}
func TestWrapToastBodyBreaksOverlongWord(t *testing.T) {
got := wrapToastBody("supercalifragilistic", 6)
if got[0] != "superc" {
t.Fatalf("line 0 = %q, want \"superc\"", got[0])
}
if got[1] != "alifra" {
t.Fatalf("line 1 = %q, want \"alifra\"", got[1])
}
// Third line should hold the rest (possibly ellipsized).
if got[2] == "" {
t.Fatalf("line 2 unexpectedly empty: %#v", got)
}
}
func TestWrapToastBodyEmptyInput(t *testing.T) {
got := wrapToastBody("", 20)
for i, l := range got {
if l != "" {
t.Fatalf("line %d = %q, want \"\"", i, l)
}
}
}