From f23a17848d7097569a58cdacffb5ac4854907de1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 22 Aug 2026 01:55:07 -0700 Subject: [PATCH 1/2] Keep rich composers rich across Reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer.Reset() unconditionally dropped the composer to quick (single-line) mode. That collapse is right for the one quick-constructed composer (chat, where auto-expansion is a temporary state), but five composers are constructed rich — and for them Reset silently demoted below their constructed mode. Two failure paths. The to-do description editor Resets its long-lived rich composer and then SetValues the existing description as Markdown: the single-line textinput sanitizes the newlines, so a multi-paragraph description flattens to one line in the editor and saves back flattened — silent data loss. The detail-comment and check-in-answer composers Reset after each send and, since both disable keystroke auto-expansion, then sat hard-stuck in single-line mode where Enter sends — multi-line replies only worked again via paste or $EDITOR, which expand programmatically. Record the constructed mode and make Reset restore it: a quick composer that auto-expanded still collapses back, and a rich composer stays rich. And close the flattening trap at its entrance — SetValue was the only content entry point that didn't auto-expand for multi-line or Markdown content the way InsertPaste and HandleEditorReturn do. Now it does, and those two reuse it instead of carrying their own expansion checks. The Reset-to-quick contract dated to an incidental hunk in the drag-and-drop commit (#209); TestComposerResetReturnsToQuickMode encoded it and is replaced by TestComposerResetRestoresConstructedMode, which covers both directions. --- internal/tui/workspace/views/todos_test.go | 16 +++++++ internal/tui/workspace/widget/composer.go | 46 ++++++++++--------- .../tui/workspace/widget/composer_test.go | 46 ++++++++++++++++--- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/internal/tui/workspace/views/todos_test.go b/internal/tui/workspace/views/todos_test.go index 2c7070506..a876b14dc 100644 --- a/internal/tui/workspace/views/todos_test.go +++ b/internal/tui/workspace/views/todos_test.go @@ -983,3 +983,19 @@ func newTextInputWithValue(val string) textinput.Model { ti.SetValue(val) return ti } + +// --- Edit description: multi-line content survives the composer --- + +func TestTodos_EditDescription_PreservesMultilineMarkdown(t *testing.T) { + v := testTodosViewWithTodos() + v.descComposer = widget.NewComposer(v.styles, widget.WithMode(widget.ComposerRich)) + + todos := sampleTodos() + todos[0].Description = "

para one

\n


\n

para two

" + v.session.Hub().Todos(42, 10).Set(todos) + + cmd := v.startEditDescription() + require.NotNil(t, cmd) + assert.Equal(t, "para one\n\npara two", v.descComposer.Value(), + "Reset must not drop the rich composer to single-line mode and flatten the description") +} diff --git a/internal/tui/workspace/widget/composer.go b/internal/tui/workspace/widget/composer.go index 2b4a860d4..51afaa834 100644 --- a/internal/tui/workspace/widget/composer.go +++ b/internal/tui/workspace/widget/composer.go @@ -114,10 +114,11 @@ func defaultComposerKeyMap() composerKeyMap { // Composer is a reusable Markdown editing widget with attachment support. type Composer struct { // Input widgets - textInput textinput.Model - textArea textarea.Model - mode ComposerMode - autoExpand bool // auto-switch quick→rich on markdown formatting + textInput textinput.Model + textArea textarea.Model + mode ComposerMode + initialMode ComposerMode // mode the composer was constructed with; Reset restores it + autoExpand bool // auto-switch quick→rich on markdown formatting // Attachments attachments []Attachment @@ -201,6 +202,7 @@ func NewComposer(styles *tui.Styles, opts ...ComposerOption) *Composer { for _, opt := range opts { opt(c) } + c.initialMode = c.mode return c } @@ -252,8 +254,13 @@ func (c *Composer) Value() string { return c.textArea.Value() } -// SetValue sets the text content (useful for pre-populating). +// SetValue sets the text content (useful for pre-populating). Multi-line or +// Markdown content expands a quick composer to rich mode first: the +// single-line textinput would silently flatten newlines. func (c *Composer) SetValue(s string) { + if c.mode == ComposerQuick && (strings.Contains(s, "\n") || richtext.IsMarkdown(s)) { + c.expandToRich() + } if c.mode == ComposerQuick { c.textInput.SetValue(s) } else { @@ -262,19 +269,20 @@ func (c *Composer) SetValue(s string) { } // InsertPaste appends pasted text at the current cursor position. -// If the text contains newlines or markdown, the composer auto-expands to rich mode. +// If the text contains newlines or markdown, SetValue auto-expands the +// composer to rich mode. func (c *Composer) InsertPaste(text string) { if text == "" { return } - if c.mode == ComposerQuick && (strings.Contains(text, "\n") || richtext.IsMarkdown(text)) { - c.expandToRich() - } - existing := c.Value() - c.SetValue(existing + text) + c.SetValue(c.Value() + text) } -// Reset clears all content, attachments, and returns to quick mode. +// Reset clears all content and attachments and returns the composer to its +// constructed mode. A quick composer that auto-expanded collapses back to +// quick; a composer built rich stays rich — dropping it to a single-line +// input would flatten the next multi-line SetValue and demote Enter from +// newline to send. func (c *Composer) Reset() { c.textInput.Reset() c.textArea.Reset() @@ -282,7 +290,7 @@ func (c *Composer) Reset() { c.attachCursor = -1 c.uploading = 0 c.preview = false - c.mode = ComposerQuick + c.mode = c.initialMode } // Attachments returns the current attachment list. @@ -743,15 +751,9 @@ func (c *Composer) HandleEditorReturn(msg EditorReturnMsg) tea.Cmd { if content == "" { return nil } - // If content has multiple lines or markdown, switch to rich mode - if strings.Contains(content, "\n") || richtext.IsMarkdown(content) { - if c.mode == ComposerQuick { - c.expandToRich() - } - c.textArea.SetValue(content) - } else { - c.SetValue(content) - } + // SetValue switches to rich mode when the content has multiple lines or + // markdown. + c.SetValue(content) return nil } diff --git a/internal/tui/workspace/widget/composer_test.go b/internal/tui/workspace/widget/composer_test.go index d97400ffd..6cb1059d8 100644 --- a/internal/tui/workspace/widget/composer_test.go +++ b/internal/tui/workspace/widget/composer_test.go @@ -57,12 +57,46 @@ func TestComposerReset(t *testing.T) { } } -func TestComposerResetReturnsToQuickMode(t *testing.T) { - c := NewComposer(testStyles(), WithMode(ComposerRich)) - c.SetValue("some text") - c.Reset() - if c.Mode() != ComposerQuick { - t.Errorf("mode after Reset = %d, want ComposerQuick", c.Mode()) +func TestComposerResetRestoresConstructedMode(t *testing.T) { + // A composer built rich stays rich across Reset: dropping to the + // single-line quick input would flatten the next multi-line SetValue + // (e.g. re-editing a to-do description) and demote Enter to send. + rich := NewComposer(testStyles(), WithMode(ComposerRich)) + rich.SetValue("some text") + rich.Reset() + if rich.Mode() != ComposerRich { + t.Errorf("mode after Reset = %d, want ComposerRich", rich.Mode()) + } + + // A quick composer that auto-expanded collapses back to quick. + quick := NewComposer(testStyles()) + quick.InsertPaste("line1\nline2") + if quick.Mode() != ComposerRich { + t.Fatalf("paste should have expanded to rich, got %d", quick.Mode()) + } + quick.Reset() + if quick.Mode() != ComposerQuick { + t.Errorf("mode after Reset = %d, want ComposerQuick", quick.Mode()) + } +} + +func TestComposerSetValueExpandsForMultilineContent(t *testing.T) { + // SetValue is a content entry point like InsertPaste and editor return: + // multi-line or Markdown content must expand a quick composer, or the + // single-line textinput silently flattens the newlines. + c := NewComposer(testStyles()) + c.SetValue("para one\n\npara two") + if c.Mode() != ComposerRich { + t.Errorf("mode after multi-line SetValue = %d, want ComposerRich", c.Mode()) + } + if c.Value() != "para one\n\npara two" { + t.Errorf("value = %q, newlines were flattened", c.Value()) + } + + single := NewComposer(testStyles()) + single.SetValue("hello") + if single.Mode() != ComposerQuick { + t.Errorf("single-line SetValue should stay quick, got %d", single.Mode()) } } From a7a600dc582e4f2e7990fab0ea0b8710d092fd9d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 22 Aug 2026 12:14:49 -0700 Subject: [PATCH 2/2] Cover the Markdown branch of SetValue's auto-expansion The test exercised the newline trigger and the plain single-line negative case but not the IsMarkdown trigger, so a refactor could drop the Markdown half of the condition without failing anything. Assert that a single-line Markdown value expands to rich mode. --- internal/tui/workspace/widget/composer_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/tui/workspace/widget/composer_test.go b/internal/tui/workspace/widget/composer_test.go index 6cb1059d8..92e35dfc0 100644 --- a/internal/tui/workspace/widget/composer_test.go +++ b/internal/tui/workspace/widget/composer_test.go @@ -93,6 +93,13 @@ func TestComposerSetValueExpandsForMultilineContent(t *testing.T) { t.Errorf("value = %q, newlines were flattened", c.Value()) } + // Single-line Markdown expands too — rendering it needs the rich editor. + markdown := NewComposer(testStyles()) + markdown.SetValue("some **bold** text") + if markdown.Mode() != ComposerRich { + t.Errorf("mode after Markdown SetValue = %d, want ComposerRich", markdown.Mode()) + } + single := NewComposer(testStyles()) single.SetValue("hello") if single.Mode() != ComposerQuick {