Skip to content

Commit f566471

Browse files
authored
Merge pull request #2784 from joshbarrington/feature/show-user-paste-content
feat(tui): show user pasted content
2 parents accbd8c + 8690e3f commit f566471

4 files changed

Lines changed: 164 additions & 10 deletions

File tree

pkg/tui/components/editor/editor.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,10 +566,32 @@ func (e *editor) resetAndSend(content string) tea.Cmd {
566566
e.tryAddFileRef(e.pendingFileRef)
567567
e.pendingFileRef = ""
568568
attachments := e.collectAttachments(content)
569+
570+
var finalAttachments []messages.Attachment
571+
var pastes []messages.Attachment
572+
573+
for _, att := range attachments {
574+
if att.Content != "" && strings.HasPrefix(att.Name, "paste-") {
575+
pastes = append(pastes, att)
576+
} else {
577+
finalAttachments = append(finalAttachments, att)
578+
}
579+
}
580+
581+
// Sort pastes by name length descending to avoid partial matches
582+
// e.g., replacing @paste-1 before @paste-10 would corrupt @paste-10.
583+
slices.SortFunc(pastes, func(a, b messages.Attachment) int {
584+
return len(b.Name) - len(a.Name)
585+
})
586+
587+
for _, att := range pastes {
588+
content = strings.ReplaceAll(content, "@"+att.Name, att.Content)
589+
}
590+
569591
e.textarea.Reset()
570592
e.userTyped = false
571593
e.clearSuggestion()
572-
return core.CmdHandler(messages.SendMsg{Content: content, Attachments: attachments})
594+
return core.CmdHandler(messages.SendMsg{Content: content, Attachments: finalAttachments})
573595
}
574596

575597
// configureNewlineKeybinding sets up the appropriate newline keybinding

pkg/tui/components/message/message.go

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import (
1414
"github.com/docker/docker-agent/pkg/tui/types"
1515
)
1616

17+
const (
18+
maxUserMessageLines = 30
19+
collapsedUserMessageLines = 5
20+
)
21+
1722
// Model represents a view that can render a message
1823
type Model interface {
1924
layout.Model
@@ -34,6 +39,7 @@ type messageModel struct {
3439
focused bool
3540
selected bool
3641
hovered bool
42+
expanded bool
3743
spinner spinner.Spinner
3844

3945
// renderCache memoizes the output of Render(width) keyed by the inputs
@@ -65,6 +71,7 @@ type renderCache struct {
6571
width int
6672
selected bool
6773
hovered bool
74+
expanded bool
6875
sameAgent bool
6976
result string
7077
}
@@ -128,6 +135,33 @@ func (mv *messageModel) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
128135
return mv, nil
129136
}
130137

138+
// Toggle switches between expanded and collapsed state.
139+
func (mv *messageModel) Toggle() {
140+
mv.expanded = !mv.expanded
141+
mv.renderCache.valid = false
142+
}
143+
144+
// IsToggleLine returns true if the line contains the expand/collapse affordance.
145+
func (mv *messageModel) IsToggleLine(lineIdx int) bool {
146+
if mv.message == nil || mv.message.Type != types.MessageTypeUser {
147+
return false
148+
}
149+
content := strings.TrimRight(mv.message.Content, "\n\r\t ")
150+
if strings.Count(content, "\n")+1 <= maxUserMessageLines {
151+
return false
152+
}
153+
154+
// The indicator is placed at the end of the message view with a leading \n\n.
155+
// Depending on edit state, the view has 0 or 1 lines of top padding,
156+
// and 1 line of bottom padding.
157+
// height-1 is the bottom padding.
158+
// height-2 is the text of the indicator ("[-] click to collapse").
159+
// height-3 is the empty line above it.
160+
// By checking >= height-3, we provide a generous clickable area exactly on the toggle.
161+
height := mv.Height(mv.width)
162+
return lineIdx >= height-3
163+
}
164+
131165
// View renders the message view
132166
func (mv *messageModel) View() string {
133167
return mv.Render(mv.width)
@@ -151,6 +185,7 @@ func (mv *messageModel) Render(width int) string {
151185
c.msgType == msg.Type &&
152186
c.selected == mv.selected &&
153187
c.hovered == mv.hovered &&
188+
c.expanded == mv.expanded &&
154189
c.content == msg.Content &&
155190
c.sameAgent == mv.sameAgentAsPrevious(msg) {
156191
return c.result
@@ -167,6 +202,7 @@ func (mv *messageModel) Render(width int) string {
167202
width: width,
168203
selected: mv.selected,
169204
hovered: mv.hovered,
205+
expanded: mv.expanded,
170206
sameAgent: mv.sameAgentAsPrevious(msg),
171207
result: result,
172208
}
@@ -199,16 +235,34 @@ func (mv *messageModel) render(width int) string {
199235
messageStyle = styles.SelectedUserMessageStyle
200236
}
201237

238+
formatUserContent := func(c string) string {
239+
c = strings.TrimRight(c, "\n\r\t ")
240+
if c == "" {
241+
return msg.Content
242+
}
243+
244+
totalLines := strings.Count(c, "\n") + 1
245+
if totalLines > maxUserMessageLines {
246+
if !mv.expanded {
247+
parts := strings.SplitN(c, "\n", collapsedUserMessageLines+1)
248+
visibleLines := strings.Join(parts[:collapsedUserMessageLines], "\n")
249+
hiddenCount := totalLines - collapsedUserMessageLines
250+
indicator := "\n\n" + styles.MutedStyle.Render(fmt.Sprintf("[+] expand %d more lines", hiddenCount))
251+
return visibleLines + indicator
252+
}
253+
indicator := "\n\n" + styles.MutedStyle.Render("[-] collapse")
254+
return c + indicator
255+
}
256+
return c
257+
}
258+
202259
if msg.SessionPosition == nil {
203-
return messageStyle.Width(width).Render(msg.Content)
260+
return messageStyle.Width(width).Render(formatUserContent(msg.Content))
204261
}
205262

206263
// For editable messages, place the pencil icon in the top padding row
207264
innerWidth := width - messageStyle.GetHorizontalFrameSize()
208-
content := strings.TrimRight(msg.Content, "\n\r\t ")
209-
if content == "" {
210-
content = msg.Content
211-
}
265+
content := formatUserContent(msg.Content)
212266

213267
// Create the edit icon for the top row
214268
editIcon := styles.MutedStyle.Render(types.UserMessageEditLabel)

pkg/tui/components/message/message_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package message
22

33
import (
4+
"fmt"
45
"regexp"
56
"strings"
67
"testing"
@@ -220,3 +221,75 @@ func TestWelcomeMessagePreservesLineBreaks(t *testing.T) {
220221
plainRendered := stripANSI(rendered)
221222
assert.Contains(t, plainRendered, "indented")
222223
}
224+
225+
func TestUserMessageCollapsible(t *testing.T) {
226+
t.Parallel()
227+
228+
// Create a user message with > 30 lines
229+
lines := make([]string, 35)
230+
for i := range 35 {
231+
lines[i] = fmt.Sprintf("Line %d", i+1)
232+
}
233+
content := strings.Join(lines, "\n")
234+
235+
msg := &types.Message{
236+
Type: types.MessageTypeUser,
237+
Content: content,
238+
}
239+
mv := New(msg, nil)
240+
mv.SetSize(80, 0)
241+
242+
// Initially, it should not be expanded.
243+
// It should render 5 lines + indicator
244+
rendered := mv.View()
245+
renderedPlain := stripANSI(rendered)
246+
247+
assert.Contains(t, renderedPlain, "Line 1")
248+
assert.Contains(t, renderedPlain, "Line 5")
249+
assert.NotContains(t, renderedPlain, "Line 6")
250+
assert.Contains(t, renderedPlain, "[+] expand 30 more lines")
251+
252+
// Test IsToggleLine
253+
// The height calculation inside IsToggleLine relies on mv.Height(80)
254+
height := mv.Height(80)
255+
assert.True(t, mv.IsToggleLine(height-1), "Bottom padding line should be toggleable")
256+
assert.True(t, mv.IsToggleLine(height-2), "Indicator text line should be toggleable")
257+
assert.True(t, mv.IsToggleLine(height-3), "Empty line above indicator should be toggleable")
258+
assert.False(t, mv.IsToggleLine(height-4), "Text content lines should not be toggleable")
259+
260+
// Toggle it
261+
mv.Toggle()
262+
263+
// Render again, should be expanded
264+
renderedExpanded := mv.View()
265+
renderedExpandedPlain := stripANSI(renderedExpanded)
266+
267+
assert.Contains(t, renderedExpandedPlain, "Line 1")
268+
assert.Contains(t, renderedExpandedPlain, "Line 35")
269+
assert.Contains(t, renderedExpandedPlain, "[-] collapse")
270+
}
271+
272+
func TestUserMessageNotCollapsible(t *testing.T) {
273+
t.Parallel()
274+
275+
// Create a user message with <= 30 lines
276+
lines := make([]string, 10)
277+
for i := range 10 {
278+
lines[i] = fmt.Sprintf("Line %d", i+1)
279+
}
280+
msg := &types.Message{
281+
Type: types.MessageTypeUser,
282+
Content: strings.Join(lines, "\n"),
283+
}
284+
mv := New(msg, nil)
285+
mv.SetSize(80, 0)
286+
287+
renderedPlain := stripANSI(mv.View())
288+
289+
assert.Contains(t, renderedPlain, "Line 10")
290+
assert.NotContains(t, renderedPlain, "[+] expand")
291+
assert.NotContains(t, renderedPlain, "[-] collapse")
292+
293+
height := mv.Height(80)
294+
assert.False(t, mv.IsToggleLine(height-1))
295+
}

pkg/tui/components/messages/messages.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ import (
3737
// ToggleHideToolResultsMsg triggers hiding/showing tool results
3838
type ToggleHideToolResultsMsg struct{}
3939

40+
type toggleableView interface {
41+
IsToggleLine(lineIdx int) bool
42+
Toggle()
43+
}
44+
4045
// Model represents a chat message list component
4146
type Model interface {
4247
layout.Model
@@ -296,11 +301,11 @@ func (m *model) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd)
296301

297302
line, col := m.mouseToLineCol(msg.X, msg.Y)
298303

299-
// Check for reasoning block header toggle
300304
if msgIdx, localLine := m.globalLineToMessageLine(line); msgIdx >= 0 {
301-
if block, ok := m.views[msgIdx].(*reasoningblock.Model); ok {
302-
if block.IsToggleLine(localLine) {
303-
block.Toggle()
305+
// Check for toggleable blocks (e.g. reasoning block, collapsed long messages)
306+
if t, ok := m.views[msgIdx].(toggleableView); ok {
307+
if t.IsToggleLine(localLine) {
308+
t.Toggle()
304309
m.bottomSlack = 0
305310
m.invalidateItem(msgIdx)
306311
return m, nil

0 commit comments

Comments
 (0)