mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* Add terminal companions * Polish interactive terminal companions * Fix terminal companion edge cases * Strengthen terminal companion regression tests * Preserve config layout when selecting pets * Handle sparse pet preference layouts * fix(terminalpet): reset SGR before erasing a sixel companion On Windows Terminal the companion draws a filled black rectangle over the interface, and dragging it mangles text under and around it. It looks like a platform quirk. It is an untested code path that only Windows reaches. Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty hands the terminal an image id and the placement goes away. A sixel is already in the cell grid, so clearRenderedImage writes literal spaces over the cells it occupied. Those spaces are ordinary characters and carry whatever SGR the TUI last set, and nothing normalised it, so every erase stamped a filled block in the ambient background. A drag erases and repaints on every pixel, so the block was continuously refreshed over the UI. It stayed invisible everywhere else because ghostty, kitty, WezTerm and iTerm all resolve to the Kitty protocol and delete by id, never reaching this code. Windows Terminal is the only mainstream terminal that lands on the sixel branch, which is why testing on Linux and macOS could not have caught it. The erase now resets SGR before painting, and uses DECSC/DECRC rather than CSI s/u because only DECSC saves the attributes this clobbers. Restoring them matters: the erase is emitted mid-frame, between styled writes the TUI has already begun. Measured before and after rather than reasoned about. Before: \x1b[s\x1b[3;41H \x1b[4;41H \x1b[5;41H \x1b[u no reset anywhere. After, the reset leads and DECRC closes. Tests assert the bytes, since bytes are what the terminal sees: SGR is reset BEFORE the first row is painted rather than merely present somewhere, the erase still covers every claimed cell so the fix cannot be satisfied by erasing nothing, and the Kitty path still deletes by id rather than acquiring the character-painting erase. Verified by mutation: restoring the old sequence fails the first of those with the exact reported symptom. Deliberately not fixed here, since both are design calls rather than defects: the erase rectangle is computed from the app's own cell maths rather than the terminal's real footprint, and the sixel path has no in-place move, so every drag pixel is a full erase and repaint where Kitty gets a cheap reposition. * fix(tui): ask sixel terminals for their cell size and erase what was painted The companion blanks a row and a column of live interface on Windows Terminal. The cause is one predicate. petPixelProtocolSupported gates two unrelated questions that only looked like one: whether pixel-precise dragging is available, and whether to ask the terminal for its cell size with CSI 16 t. It answers true for Kitty only, so on a sixel terminal the request was never sent, the reply never arrived, and petCellPixelWidth/Height stayed zero for the whole session. Everything downstream then took its fallback. petImageHeightPixels returned its blind preferredHeight of 75 pixels instead of the cell-aware branch below it, and the erase in clearRenderedImage used petImageColumns and petImageRows. Those constants describe the RESERVED area, not what was painted. Windows Terminal reports a 20x10 cell, so a 75-pixel sprite covers 4 rows and about 8 columns while the erase blanked 5 by 9, taking a row and a column of interface with it on every move. A drag repaints per pixel, so it did that continuously. Windows Terminal answers CSI 16 t, confirmed by hand: it replies ESC [6;20;10t. This was never a question of terminal support. Nothing asked. So the request now goes out for every image protocol, via a predicate separate from the dragging one, and the sixel footprint is computed from the rendered pixel height and the reported cell size rather than from the constants. It is still clamped to the reserved area, because the layout keeps that many cells clear and the drag clamps to it, so a larger computed footprint would mean erasing into live interface. Deliberately NOT widening petPixelProtocolSupported itself: that would enable pixel-precise dragging on sixel as a side effect, which is a separate feature decision rather than part of this defect. Kitty is untouched and a test pins that. There Columns and Rows are the placement REQUEST handed to the terminal, which owns the region and scales into it, so recomputing them would resize the image rather than fix an erase. Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty keeps its requested placement, the fallback without metrics stays the reserved area rather than collapsing to zero and erasing nothing, and every image protocol asks for the measurement. * Keep Sixel companions in the reserved dock --------- Co-authored-by: Vasanthdev2004 <[email protected]>
63 lines
1.9 KiB
Go
63 lines
1.9 KiB
Go
package tui
|
||
|
||
import tea "charm.land/bubbletea/v2"
|
||
|
||
// moveModalSelection moves the highlight on an open selection surface by delta
|
||
// (−1 previous / +1 next), matching arrow-key behavior for that surface.
|
||
// handled is false when no list/selector modal owns the keys (caller may fall
|
||
// through to plan toggle, composer, etc.).
|
||
//
|
||
// Surfaces: permission options, ask_user option lists, provider/MCP wizards and
|
||
// managers, command pickers, slash/file autocomplete. Does not scroll the
|
||
// transcript or drive composer history — those stay arrow-only.
|
||
func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
|
||
if delta == 0 {
|
||
return m, nil, false
|
||
}
|
||
if m.pendingPermission != nil {
|
||
return m.movePermissionCursor(delta), nil, true
|
||
}
|
||
if m.pendingAskUser != nil {
|
||
// moveAskUserCursor is a no-op in free-text mode (parity with arrows).
|
||
return m.moveAskUserCursor(delta), nil, true
|
||
}
|
||
if m.providerWizard != nil {
|
||
m.burstCount = 0
|
||
next, cmd := m.handleProviderWizardKey(syntheticArrowKey(delta))
|
||
return next, cmd, true
|
||
}
|
||
if m.mcpAddWizard != nil {
|
||
m.burstCount = 0
|
||
next, cmd := m.handleMCPAddWizardKey(syntheticArrowKey(delta))
|
||
return next, cmd, true
|
||
}
|
||
if m.mcpManager != nil {
|
||
m.burstCount = 0
|
||
next, cmd := m.handleMCPManagerKey(syntheticArrowKey(delta))
|
||
return next, cmd, true
|
||
}
|
||
if m.picker != nil {
|
||
if m.modelPickerIsLoading() {
|
||
return m, nil, true
|
||
}
|
||
m, cmd := m.pickerMoved(delta)
|
||
return m, cmd, true
|
||
}
|
||
if m.suggestionsActive() {
|
||
m.moveSuggestion(delta)
|
||
return m, nil, true
|
||
}
|
||
return m, nil, false
|
||
}
|
||
|
||
// syntheticArrowKey builds a KeyUp (delta < 0) or KeyDown (delta > 0) message so
|
||
// wizard/manager handlers that only match arrow keys can share one code path
|
||
// with Ctrl+P / Ctrl+N.
|
||
func syntheticArrowKey(delta int) tea.KeyPressMsg {
|
||
code := tea.KeyDown
|
||
if delta < 0 {
|
||
code = tea.KeyUp
|
||
}
|
||
return tea.KeyPressMsg(tea.Key{Code: code})
|
||
}
|