Compare commits
4 Commits
e6f5a94fae
...
v0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9b8e71c6 | |||
| e64060e40f | |||
| e4ab8c2136 | |||
| f312b6d345 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -6,6 +6,32 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.5] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
- Replaced the single-slot status-line "flash" with a stackable toast
|
||||
surface anchored at the top-right of the focused pane. `flashError`,
|
||||
`flashTransient`, and MCP `request_human_attention` now push onto
|
||||
the toast stack (cap 5, oldest drops). Toasts persist until
|
||||
dismissed with `Ctrl-N`, or cleared via the new
|
||||
"Clear notifications" palette command. The status line no longer
|
||||
shows the `[!]` prefix.
|
||||
- `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
|
||||
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
|
||||
|
||||
### Changed
|
||||
|
||||
3
TODO.md
3
TODO.md
@@ -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.
|
||||
|
||||
@@ -432,10 +432,11 @@ type uiState struct {
|
||||
repaintNextPTY string
|
||||
repaintNextPTYBudget int
|
||||
|
||||
// attention is the latest request_human_attention surfaced via MCP;
|
||||
// rendered in the status line until cleared.
|
||||
attentionText string
|
||||
attentionAt string
|
||||
// toasts is the stackable notification surface. flashError,
|
||||
// flashTransient, and notifyAttention all push onto it; the user
|
||||
// dismisses entries with Ctrl-N or the "Clear notifications"
|
||||
// palette command.
|
||||
toasts toastStack
|
||||
|
||||
// pendingTrust is the most recent trust prompt — surfaced in the
|
||||
// status line until the user resolves it with Ctrl-K. v1 keeps the
|
||||
@@ -722,20 +723,15 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
|
||||
}
|
||||
|
||||
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||
// surface a one-line toast in the status row and remember the most
|
||||
// recent ask so the status line keeps showing it. The sidebar-blink is
|
||||
// deferred until the §4 chrome lands.
|
||||
// push a toast onto the stack; the focused-pane render path picks it
|
||||
// up. The sidebar-blink is deferred until the §4 chrome lands.
|
||||
func (st *uiState) notifyAttention(childID, reason string) {
|
||||
c := st.sess.FindChild(childID)
|
||||
name := childID
|
||||
if c != nil {
|
||||
name = c.DisplayName()
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
|
||||
st.attentionAt = childID
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
st.notifyToast(toastAttention, fmt.Sprintf("%s — %s", name, reason))
|
||||
}
|
||||
|
||||
func (st *uiState) scratchpadsChanged() {
|
||||
@@ -1167,8 +1163,6 @@ func (st *uiState) drawStatusLine() {
|
||||
palOpen := st.palette != nil
|
||||
focusID := st.focusedID
|
||||
focusName := st.focusedName
|
||||
attention := st.attentionText
|
||||
attentionAt := st.attentionAt
|
||||
var trustMsg string
|
||||
if st.pendingTrust != nil {
|
||||
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
|
||||
@@ -1208,13 +1202,6 @@ func (st *uiState) drawStatusLine() {
|
||||
left = owner
|
||||
}
|
||||
}
|
||||
if attention != "" && attentionAt == focusID {
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if attention != "" && attentionAt == "" {
|
||||
// Sticky attention/flash from somewhere outside the focused pane.
|
||||
left = "[!] " + attention
|
||||
}
|
||||
if trustMsg != "" {
|
||||
left = "[trust] " + trustMsg
|
||||
}
|
||||
@@ -1270,8 +1257,6 @@ func (st *uiState) drawStatusLine() {
|
||||
// child is focused.
|
||||
func (st *uiState) renderEmptyState() {
|
||||
layout := st.layoutSnapshot()
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
line := "Press Ctrl-K to spawn an agent or process"
|
||||
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
|
||||
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
|
||||
@@ -1281,7 +1266,10 @@ func (st *uiState) renderEmptyState() {
|
||||
if col < int(layout.mainLeft) {
|
||||
col = int(layout.mainLeft)
|
||||
}
|
||||
st.outMu.Lock()
|
||||
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
|
||||
st.outMu.Unlock()
|
||||
st.renderToasts()
|
||||
}
|
||||
|
||||
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
|
||||
@@ -1412,6 +1400,7 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
var pendingViewportBottom bool
|
||||
var pendingPadStep int
|
||||
var pendingPadExit bool
|
||||
var pendingDismissToast bool
|
||||
|
||||
flushForward := func() {
|
||||
if len(forward) == 0 {
|
||||
@@ -1598,6 +1587,11 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
|
||||
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit {
|
||||
} else if hit, _ := matchCtrlChar(chunk, i, 's'); hit {
|
||||
} else if hit, _ := matchCtrlChar(chunk, i, 'n'); hit {
|
||||
// Ctrl-N is the toast dismiss key. In pad view we
|
||||
// allow it through the chord block so the handler
|
||||
// below can fire even though pads otherwise swallow
|
||||
// bytes.
|
||||
} else {
|
||||
i++
|
||||
continue
|
||||
@@ -1696,6 +1690,22 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Ctrl-N dismisses the most recent toast. We only consume the
|
||||
// chord when there's actually a toast to dismiss; otherwise the
|
||||
// bytes fall through to the focused PTY so readline /
|
||||
// nano / emacs / opencode keep working in shells and editors.
|
||||
if hit, adv := matchCtrlChar(chunk, i, 'n'); hit {
|
||||
if st.toasts.length() > 0 {
|
||||
flushForward()
|
||||
pendingDismissToast = true
|
||||
i += adv
|
||||
continue
|
||||
}
|
||||
forward = append(forward, chunk[i:i+adv]...)
|
||||
i += adv
|
||||
continue
|
||||
}
|
||||
|
||||
// Ctrl-B snaps the focused child's emulator viewport back to the
|
||||
// active area. Use this as the escape hatch from a scrolled-up
|
||||
// state — wheel scrolls move the viewport into the libghostty
|
||||
@@ -1777,6 +1787,11 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
if pendingPadExit {
|
||||
st.exitPadView()
|
||||
}
|
||||
if pendingDismissToast {
|
||||
if st.toasts.dismissTop() {
|
||||
st.refreshToastSurface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scrollFocusedViewport scrolls the focused child's emulator viewport by
|
||||
@@ -1985,6 +2000,11 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
case "quit":
|
||||
st.requestExit()
|
||||
|
||||
case "toasts-clear":
|
||||
if st.toasts.clear() {
|
||||
st.refreshToastSurface()
|
||||
}
|
||||
|
||||
case "pad-delete":
|
||||
st.handlePadDelete(action.padName)
|
||||
|
||||
@@ -2261,37 +2281,18 @@ func (st *uiState) handleProcRestart(childID string) {
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// flashError surfaces a spawn/etc. failure in the status line until the
|
||||
// next attention update overwrites it. stderr is hidden under the alt
|
||||
// screen so we can't rely on Fprintln(os.Stderr).
|
||||
// flashError surfaces a spawn/etc. failure as an error toast over the
|
||||
// focused pane. stderr is hidden under the alt screen so we can't rely
|
||||
// on Fprintln(os.Stderr).
|
||||
func (st *uiState) flashError(msg string) {
|
||||
st.mu.Lock()
|
||||
st.attentionText = msg
|
||||
st.attentionAt = "" // shows on every focus until cleared
|
||||
focusedPad := st.focusedPad
|
||||
focusedID := st.focusedID
|
||||
st.mu.Unlock()
|
||||
switch {
|
||||
case focusedPad != "":
|
||||
st.repaintFocusedPad()
|
||||
case focusedID != "":
|
||||
st.repaintFocused()
|
||||
default:
|
||||
st.renderEmptyState()
|
||||
}
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
st.notifyToast(toastError, msg)
|
||||
}
|
||||
|
||||
// flashTransient is the softer cousin of flashError used for
|
||||
// trust-prompt resolutions. Same status-line surface; the prefix differs.
|
||||
// trust-prompt resolutions and other ack-style notices. Same
|
||||
// stackable surface, info styling.
|
||||
func (st *uiState) flashTransient(msg string) {
|
||||
st.mu.Lock()
|
||||
st.attentionText = msg
|
||||
st.attentionAt = ""
|
||||
st.mu.Unlock()
|
||||
st.drawStatusLine()
|
||||
st.notifyToast(toastInfo, msg)
|
||||
}
|
||||
|
||||
// repaintFocused redraws the current focused child's screen snapshot.
|
||||
@@ -2335,8 +2336,9 @@ func (st *uiState) repaintFocused() {
|
||||
}
|
||||
st.mu.Unlock()
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.Write(out)
|
||||
st.outMu.Unlock()
|
||||
st.renderToasts()
|
||||
}
|
||||
|
||||
// repaintFocusedPad paints the focused scratchpad's content into the
|
||||
@@ -2360,8 +2362,9 @@ func (st *uiState) repaintFocusedPad() {
|
||||
return
|
||||
}
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.Write(out)
|
||||
st.outMu.Unlock()
|
||||
st.renderToasts()
|
||||
}
|
||||
|
||||
// renderPadView builds the bytes that paint a scratchpad's content
|
||||
|
||||
@@ -54,14 +54,6 @@ const (
|
||||
groupQuit
|
||||
)
|
||||
|
||||
var groupLabels = map[int]string{
|
||||
groupFocused: "Focused",
|
||||
groupOpen: "Open",
|
||||
groupSpawn: "Spawn",
|
||||
groupSettings: "Settings",
|
||||
groupQuit: "Quit",
|
||||
}
|
||||
|
||||
type paletteItem struct {
|
||||
label string
|
||||
hint string
|
||||
@@ -205,8 +197,10 @@ func (p *paletteState) rebuild() {
|
||||
all := p.buildItems(macro)
|
||||
|
||||
if rest == "" {
|
||||
// No textual filter: render with section headers between groups.
|
||||
p.items = itemsWithHeaders(all)
|
||||
// No textual filter: render with blank spacer rows between
|
||||
// groups so sections read as scannable bands without dashed
|
||||
// headers stealing visual weight.
|
||||
p.items = itemsWithSpacers(all)
|
||||
p.clampCursor()
|
||||
return
|
||||
}
|
||||
@@ -243,25 +237,28 @@ func (p *paletteState) rebuild() {
|
||||
}
|
||||
|
||||
// buildItems assembles every selectable row in fixed group order
|
||||
// (Focused → Open → Spawn → Quit). Headers are added by
|
||||
// itemsWithHeaders for the no-query case; scored mode drops them.
|
||||
// (Focused → Open → Spawn → Quit). Blank spacer rows are added by
|
||||
// itemsWithSpacers for the no-query case; scored mode drops them.
|
||||
// When macro is non-empty the result is filtered down to the kinds
|
||||
// that macro retains.
|
||||
func (p *paletteState) buildItems(macro string) []paletteItem {
|
||||
var out []paletteItem
|
||||
|
||||
// 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 {
|
||||
case p.focusedPad != "":
|
||||
name := p.focusedPad
|
||||
out = append(out,
|
||||
paletteItem{label: "Delete scratchpad: " + name, hint: "remove the file from disk",
|
||||
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)",
|
||||
paletteItem{label: "Edit", hint: "edit scratchpad · " + name + " (opens $EDITOR)",
|
||||
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 != "":
|
||||
if c := findChildByID(p.children, p.focused); c != nil {
|
||||
@@ -269,40 +266,39 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
|
||||
switch c.Kind {
|
||||
case KindAgent:
|
||||
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},
|
||||
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},
|
||||
)
|
||||
default:
|
||||
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},
|
||||
paletteItem{label: "Delete process: " + name, hint: "remove entry; SIGKILL if alive",
|
||||
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
|
||||
paletteItem{label: "Stop process: " + name, hint: "SIGTERM · keep entry for restart",
|
||||
paletteItem{label: "Stop", hint: "stop process · " + name + " (SIGTERM, keeps entry)",
|
||||
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},
|
||||
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
|
||||
// remain so they can be restarted. The currently-focused child is
|
||||
// marked with a leading ▶ instead of the older "• … (current)" suffix
|
||||
// so the row reads cleaner.
|
||||
// remain so they can be restarted.
|
||||
for _, c := range p.children {
|
||||
if c.ID == p.focused {
|
||||
continue
|
||||
}
|
||||
if c.Kind == KindAgent && c.Status() != StatusRunning {
|
||||
continue
|
||||
}
|
||||
label := "Switch to " + c.DisplayName()
|
||||
hint := strings.Join(c.Argv, " ")
|
||||
if c.ID == p.focused {
|
||||
label = "▶ " + label
|
||||
}
|
||||
if c.Status() != StatusRunning {
|
||||
label = label + " [" + string(c.Status()) + "]"
|
||||
}
|
||||
@@ -353,6 +349,12 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
|
||||
action: paletteAction{kind: "settings-open"},
|
||||
group: groupSettings,
|
||||
})
|
||||
out = append(out, paletteItem{
|
||||
label: "Clear notifications",
|
||||
hint: "dismiss all toasts in the top-right of the focused pane",
|
||||
action: paletteAction{kind: "toasts-clear"},
|
||||
group: groupSettings,
|
||||
})
|
||||
|
||||
// Group 4: Quit.
|
||||
out = append(out, paletteItem{
|
||||
@@ -378,9 +380,11 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
|
||||
return out
|
||||
}
|
||||
|
||||
// itemsWithHeaders splices a non-selectable header row in front of
|
||||
// each new group so the (unfiltered) list reads as scannable bands.
|
||||
func itemsWithHeaders(items []paletteItem) []paletteItem {
|
||||
// itemsWithSpacers splices a non-selectable blank row between groups
|
||||
// so the (unfiltered) list reads as scannable bands without dashed
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
@@ -388,16 +392,13 @@ func itemsWithHeaders(items []paletteItem) []paletteItem {
|
||||
currentGroup := -1
|
||||
for _, it := range items {
|
||||
if it.group != currentGroup {
|
||||
currentGroup = it.group
|
||||
label, ok := groupLabels[it.group]
|
||||
if !ok {
|
||||
label = ""
|
||||
if currentGroup != -1 {
|
||||
result = append(result, paletteItem{
|
||||
action: paletteAction{kind: "header"},
|
||||
group: it.group,
|
||||
})
|
||||
}
|
||||
result = append(result, paletteItem{
|
||||
label: "── " + label + " ──",
|
||||
action: paletteAction{kind: "header"},
|
||||
group: it.group,
|
||||
})
|
||||
currentGroup = it.group
|
||||
}
|
||||
result = append(result, it)
|
||||
}
|
||||
|
||||
@@ -31,16 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
|
||||
|
||||
func TestContextItemsScratchpad(t *testing.T) {
|
||||
p := newPalette(nil, "", "notes.md", preset.Set{})
|
||||
// pad-delete is the first selectable row; the Focused section header
|
||||
// (a non-selectable row) sits above it.
|
||||
if i, _ := findItem(p, "pad-delete"); i != 1 {
|
||||
t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i)
|
||||
// With the dashed section header gone, pad-edit is the first row;
|
||||
// pad-rename-form follows, with destructive pad-delete last in the
|
||||
// Focused section.
|
||||
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" {
|
||||
t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)
|
||||
}
|
||||
if _, it := findItem(p, "pad-edit"); it == nil {
|
||||
t.Fatalf("pad-edit missing")
|
||||
if i, _ := findItem(p, "pad-delete"); i < 0 {
|
||||
t.Fatalf("pad-delete missing")
|
||||
}
|
||||
// No focused child → no agent/proc context items.
|
||||
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
|
||||
@@ -83,8 +84,11 @@ func TestContextItemsProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextItemsAppearAboveSwitch(t *testing.T) {
|
||||
c := makeFakeChild("pid", "devserver", KindCommand)
|
||||
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
|
||||
// Two children so there's still a non-focused switch entry to compare
|
||||
// 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")
|
||||
switchIdx, _ := findItem(p, "switch")
|
||||
if procIdx < 0 || switchIdx < 0 {
|
||||
|
||||
@@ -57,22 +57,45 @@ func TestPaletteDropsGlobalCloseList(t *testing.T) {
|
||||
|
||||
// -- 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)
|
||||
p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
|
||||
wantSections := []string{"Focused", "Open", "Spawn", "Quit"}
|
||||
for _, w := range wantSections {
|
||||
found := false
|
||||
for _, it := range p.items {
|
||||
if it.action.kind == "header" && strings.Contains(it.label, w) {
|
||||
found = true
|
||||
break
|
||||
other := makeFakeChild("b", "worker", KindCommand)
|
||||
p := newPalette([]*Child{c, other}, "a", "",
|
||||
preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
|
||||
|
||||
if len(p.items) == 0 {
|
||||
t.Fatalf("palette built no items")
|
||||
}
|
||||
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
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("section header %q missing from items", w)
|
||||
// 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 transitions == 0 {
|
||||
t.Fatalf("no section transitions found in palette items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteCursorSkipsHeaders(t *testing.T) {
|
||||
|
||||
288
internal/app/toast.go
Normal file
288
internal/app/toast.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// toastKind classifies a toast for styling and for migrating the
|
||||
// pre-existing flashError / flashTransient / notifyAttention call
|
||||
// sites onto the new stack.
|
||||
type toastKind int
|
||||
|
||||
const (
|
||||
toastInfo toastKind = iota
|
||||
toastError
|
||||
toastAttention
|
||||
)
|
||||
|
||||
// toast is one entry in the host-level notification stack. Toasts
|
||||
// persist until the user dismisses them with Ctrl-N or the
|
||||
// "Clear notifications" palette command — there's no auto-expiry.
|
||||
type toast struct {
|
||||
id uint64
|
||||
kind toastKind
|
||||
text string
|
||||
}
|
||||
|
||||
// toastStackCap caps how many toasts can be visible at once.
|
||||
// Older entries drop off the bottom when a new push would exceed it.
|
||||
const toastStackCap = 5
|
||||
|
||||
// toastBoxMaxWidth bounds the rendered box width so a wide pane
|
||||
// doesn't produce huge toasts. Boxes shrink below this when the pane
|
||||
// is narrow.
|
||||
const toastBoxMaxWidth = 50
|
||||
|
||||
// toastBoxMinWidth is the floor below which we refuse to render —
|
||||
// any narrower and there's not enough room for borders + content.
|
||||
const toastBoxMinWidth = 20
|
||||
|
||||
// toastStack owns the ordered list of live toasts. Oldest at
|
||||
// index 0, newest (visually topmost) at the end. The stack's own
|
||||
// mutex is intentionally separate from uiState.mu so push / dismiss
|
||||
// can be called from any goroutine without participating in the
|
||||
// host's bigger lock-ordering rules.
|
||||
type toastStack struct {
|
||||
mu sync.Mutex
|
||||
items []toast
|
||||
next uint64
|
||||
}
|
||||
|
||||
func (s *toastStack) push(kind toastKind, text string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.next++
|
||||
s.items = append(s.items, toast{id: s.next, kind: kind, text: text})
|
||||
if len(s.items) > toastStackCap {
|
||||
s.items = s.items[len(s.items)-toastStackCap:]
|
||||
}
|
||||
}
|
||||
|
||||
// dismissTop pops the most recent toast (the one rendered at the
|
||||
// top of the stack). Returns true if something was removed so
|
||||
// callers can decide whether to repaint.
|
||||
func (s *toastStack) dismissTop() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.items) == 0 {
|
||||
return false
|
||||
}
|
||||
s.items = s.items[:len(s.items)-1]
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *toastStack) clear() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.items) == 0 {
|
||||
return false
|
||||
}
|
||||
s.items = s.items[:0]
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *toastStack) snapshot() []toast {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]toast, len(s.items))
|
||||
copy(out, s.items)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *toastStack) length() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.items)
|
||||
}
|
||||
|
||||
// notifyToast is the single entry point that the former flash
|
||||
// helpers now delegate to. It pushes onto the stack and triggers a
|
||||
// repaint of the focused surface so the new toast appears
|
||||
// immediately; the repaint path also re-renders the stack on top.
|
||||
func (st *uiState) notifyToast(kind toastKind, text string) {
|
||||
st.toasts.push(kind, text)
|
||||
st.refreshToastSurface()
|
||||
}
|
||||
|
||||
// refreshToastSurface re-renders whatever surface the toasts are
|
||||
// drawn over (focused child, focused pad, or the empty-state
|
||||
// canvas). Each of those paths calls renderToasts at the end, so
|
||||
// the toast layer is always reapplied on top of a freshly-drawn
|
||||
// pane. Centralised so push / dismiss / clear share one code path.
|
||||
func (st *uiState) refreshToastSurface() {
|
||||
st.mu.Lock()
|
||||
focusedPad := st.focusedPad
|
||||
focusedID := st.focusedID
|
||||
palOpen := st.palette != nil
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
// Palette owns the whole screen while it's open; toasts will
|
||||
// repaint via closePalette's restore path.
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case focusedPad != "":
|
||||
st.repaintFocusedPad()
|
||||
case focusedID != "":
|
||||
st.repaintFocused()
|
||||
default:
|
||||
st.renderEmptyState()
|
||||
}
|
||||
}
|
||||
|
||||
// renderToasts draws the toast stack over the top-right of the
|
||||
// focused pane. Called from repaintFocused / repaintFocusedPad /
|
||||
// renderEmptyState after they finish so toasts always sit on top of
|
||||
// freshly-redrawn pane content. Safe to call when the stack is
|
||||
// empty (no-op).
|
||||
func (st *uiState) renderToasts() {
|
||||
items := st.toasts.snapshot()
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
palOpen := st.palette != nil
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
return
|
||||
}
|
||||
layout := st.layoutSnapshot()
|
||||
paneCols := int(layout.childCols())
|
||||
paneRows := int(layout.childRows())
|
||||
if paneCols < toastBoxMinWidth+2 || paneRows < 3 {
|
||||
return
|
||||
}
|
||||
boxWidth := toastBoxMaxWidth
|
||||
if max := paneCols - 4; max < boxWidth {
|
||||
boxWidth = max
|
||||
}
|
||||
if boxWidth < toastBoxMinWidth {
|
||||
return
|
||||
}
|
||||
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b7\x1b[?25l")
|
||||
|
||||
row := int(layout.mainTop) + 1
|
||||
col := int(layout.mainLeft) + paneCols - boxWidth - 1
|
||||
if col < int(layout.mainLeft) {
|
||||
col = int(layout.mainLeft)
|
||||
}
|
||||
|
||||
// Render newest first (visually on top), iterating items in
|
||||
// reverse so the most recent push lands at the smallest row.
|
||||
for idx := len(items) - 1; idx >= 0; idx-- {
|
||||
t := items[idx]
|
||||
isTopmost := idx == len(items)-1
|
||||
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.
|
||||
if row+height > int(layout.mainTop)+paneRows {
|
||||
break
|
||||
}
|
||||
border := toastBorderStyle(t.kind)
|
||||
|
||||
// Top border.
|
||||
moveTo(&b, row, col)
|
||||
b.WriteString(border)
|
||||
b.WriteString("╭")
|
||||
b.WriteString(strings.Repeat("─", boxWidth-2))
|
||||
b.WriteString("╮")
|
||||
b.WriteString(styleReset)
|
||||
row++
|
||||
|
||||
// Content row.
|
||||
moveTo(&b, row, col)
|
||||
b.WriteString(border)
|
||||
b.WriteString("│")
|
||||
b.WriteString(styleReset)
|
||||
b.WriteString(" ")
|
||||
b.WriteString(toastIcon(t.kind))
|
||||
body := t.text
|
||||
bodyRoom := contentWidth - 2 // icon + space
|
||||
if visibleLen(body) > bodyRoom {
|
||||
body = clipRunes(body, bodyRoom-1) + "…"
|
||||
}
|
||||
b.WriteString(body)
|
||||
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(body))))
|
||||
b.WriteString(" ")
|
||||
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(border)
|
||||
b.WriteString("│")
|
||||
b.WriteString(styleReset)
|
||||
row++
|
||||
}
|
||||
|
||||
// Bottom border.
|
||||
moveTo(&b, row, col)
|
||||
b.WriteString(border)
|
||||
b.WriteString("╰")
|
||||
b.WriteString(strings.Repeat("─", boxWidth-2))
|
||||
b.WriteString("╯")
|
||||
b.WriteString(styleReset)
|
||||
row++
|
||||
|
||||
// 1-row gap between stacked toasts.
|
||||
row++
|
||||
}
|
||||
|
||||
b.WriteString("\x1b[?25h\x1b8")
|
||||
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.WriteString(b.String())
|
||||
}
|
||||
|
||||
func toastBorderStyle(kind toastKind) string {
|
||||
switch kind {
|
||||
case toastError:
|
||||
return styleError
|
||||
case toastAttention:
|
||||
return styleAccent
|
||||
default:
|
||||
return styleBorder
|
||||
}
|
||||
}
|
||||
|
||||
func toastIcon(kind toastKind) string {
|
||||
switch kind {
|
||||
case toastError:
|
||||
return styleError + "✗ " + styleReset
|
||||
case toastAttention:
|
||||
return styleAccent + "! " + styleReset
|
||||
default:
|
||||
return styleHint + "• " + styleReset
|
||||
}
|
||||
}
|
||||
100
internal/app/toast_test.go
Normal file
100
internal/app/toast_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestToastStackPushAndOrder(t *testing.T) {
|
||||
var s toastStack
|
||||
s.push(toastInfo, "one")
|
||||
s.push(toastError, "two")
|
||||
s.push(toastAttention, "three")
|
||||
|
||||
snap := s.snapshot()
|
||||
if len(snap) != 3 {
|
||||
t.Fatalf("snapshot len = %d, want 3", len(snap))
|
||||
}
|
||||
if snap[0].text != "one" || snap[1].text != "two" || snap[2].text != "three" {
|
||||
t.Fatalf("snapshot order wrong: %#v", snap)
|
||||
}
|
||||
if snap[0].kind != toastInfo || snap[1].kind != toastError || snap[2].kind != toastAttention {
|
||||
t.Fatalf("snapshot kinds wrong: %#v", snap)
|
||||
}
|
||||
// IDs strictly increase.
|
||||
if !(snap[0].id < snap[1].id && snap[1].id < snap[2].id) {
|
||||
t.Fatalf("ids not increasing: %#v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToastStackCapDropsOldest(t *testing.T) {
|
||||
var s toastStack
|
||||
for i := 0; i < toastStackCap+3; i++ {
|
||||
s.push(toastInfo, "msg")
|
||||
}
|
||||
snap := s.snapshot()
|
||||
if len(snap) != toastStackCap {
|
||||
t.Fatalf("len = %d, want %d", len(snap), toastStackCap)
|
||||
}
|
||||
// The earliest IDs should have been dropped, leaving the highest
|
||||
// toastStackCap IDs.
|
||||
for i := 1; i < len(snap); i++ {
|
||||
if snap[i].id <= snap[i-1].id {
|
||||
t.Fatalf("ordering broken after cap: %#v", snap)
|
||||
}
|
||||
}
|
||||
// First retained id should be 4 (1,2,3 dropped; cap=5 leaves 4..8).
|
||||
want := uint64(toastStackCap + 3 - toastStackCap + 1)
|
||||
if snap[0].id != want {
|
||||
t.Fatalf("first retained id = %d, want %d", snap[0].id, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToastStackDismissTop(t *testing.T) {
|
||||
var s toastStack
|
||||
if s.dismissTop() {
|
||||
t.Fatalf("dismissTop on empty stack returned true")
|
||||
}
|
||||
s.push(toastInfo, "a")
|
||||
s.push(toastError, "b")
|
||||
if !s.dismissTop() {
|
||||
t.Fatalf("dismissTop returned false with items present")
|
||||
}
|
||||
snap := s.snapshot()
|
||||
if len(snap) != 1 || snap[0].text != "a" {
|
||||
t.Fatalf("after dismissTop: %#v", snap)
|
||||
}
|
||||
if !s.dismissTop() {
|
||||
t.Fatalf("dismissTop on last item returned false")
|
||||
}
|
||||
if s.length() != 0 {
|
||||
t.Fatalf("length after final dismiss = %d, want 0", s.length())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToastStackClear(t *testing.T) {
|
||||
var s toastStack
|
||||
if s.clear() {
|
||||
t.Fatalf("clear on empty returned true")
|
||||
}
|
||||
s.push(toastInfo, "a")
|
||||
s.push(toastError, "b")
|
||||
s.push(toastAttention, "c")
|
||||
if !s.clear() {
|
||||
t.Fatalf("clear returned false with items present")
|
||||
}
|
||||
if s.length() != 0 {
|
||||
t.Fatalf("length after clear = %d, want 0", s.length())
|
||||
}
|
||||
if snap := s.snapshot(); snap != nil {
|
||||
t.Fatalf("snapshot after clear = %#v, want nil", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToastStackSnapshotIsCopy(t *testing.T) {
|
||||
var s toastStack
|
||||
s.push(toastInfo, "a")
|
||||
snap := s.snapshot()
|
||||
snap[0].text = "mutated"
|
||||
again := s.snapshot()
|
||||
if again[0].text != "a" {
|
||||
t.Fatalf("snapshot is not an independent copy: %#v", again)
|
||||
}
|
||||
}
|
||||
32
internal/harness/scenarios/toast_dismiss.json
Normal file
32
internal/harness/scenarios/toast_dismiss.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "toast_dismiss",
|
||||
"presets": {
|
||||
"processes": [
|
||||
{
|
||||
"name": "steady",
|
||||
"argv": ["sh", "-lc", "printf 'STEADY READY\\n'; sleep 30"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"trust": ["steady"],
|
||||
"steps": [
|
||||
{
|
||||
"type": "mcp_call",
|
||||
"method": "spawn_process",
|
||||
"params": {"kind": "command", "preset": "steady", "name": "steady"},
|
||||
"save_as": "proc"
|
||||
},
|
||||
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
|
||||
{
|
||||
"type": "mcp_call",
|
||||
"method": "request_human_attention",
|
||||
"params": {"process_id": "{{proc.process_id}}", "reason": "needs eyes on the deploy"}
|
||||
},
|
||||
{ "type": "wait_text", "contains": "needs eyes on the deploy", "timeout_ms": 5000 },
|
||||
{ "type": "assert_contains", "contains": "STEADY READY" },
|
||||
{ "type": "send_chord", "chord": "ctrl-n" },
|
||||
{ "type": "wait_stable", "timeout_ms": 2000 },
|
||||
{ "type": "assert_contains", "contains": "STEADY READY" },
|
||||
{ "type": "assert_not_contains", "contains": "needs eyes on the deploy" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user