diff --git a/pkg/tui/components/message/message.go b/pkg/tui/components/message/message.go index 3fd6a0528..8eafd4f5d 100644 --- a/pkg/tui/components/message/message.go +++ b/pkg/tui/components/message/message.go @@ -145,22 +145,19 @@ func (mv *messageModel) Render(width int) string { prefix = mv.senderPrefix(msg.Sender) } - // Show copy icon in the top-right corner when hovered or selected. - // AssistantMessageStyle has PaddingTop=0 (unlike UserMessageStyle which has - // PaddingTop=1), so we cannot unconditionally prepend topRow+"\n" — doing so - // would add a spurious blank line to every message in the default state. - // Accept the 1-line layout shift on hover; it is less disruptive than the - // blank-line artifact that affects all messages at all times. + // Always reserve a top row to avoid layout shifts when the copy icon + // appears on hover. When not hovered, the row is filled with spaces + // (invisible). AssistantMessageStyle has PaddingTop=0, so this extra + // row acts as a stable spacer. + innerWidth := width - messageStyle.GetHorizontalFrameSize() + topRow := strings.Repeat(" ", innerWidth) if mv.hovered || mv.selected { - innerWidth := width - messageStyle.GetHorizontalFrameSize() copyIcon := styles.MutedStyle.Render(types.AssistantMessageCopyLabel) iconWidth := ansi.StringWidth(types.AssistantMessageCopyLabel) padding := max(innerWidth-iconWidth, 0) - topRow := strings.Repeat(" ", padding) + copyIcon - noTopPaddingStyle := messageStyle.PaddingTop(0) - return prefix + noTopPaddingStyle.Width(width).Render(topRow+"\n"+rendered) + topRow = strings.Repeat(" ", padding) + copyIcon } - return prefix + messageStyle.Render(rendered) + return prefix + messageStyle.Width(width).Render(topRow+"\n"+rendered) case types.MessageTypeShellOutput: if rendered, err := markdown.NewRenderer(width).Render(fmt.Sprintf("```console\n%s\n```", msg.Content)); err == nil { return rendered