Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
454 changes: 421 additions & 33 deletions internal/richtext/richtext.go

Large diffs are not rendered by default.

367 changes: 358 additions & 9 deletions internal/richtext/richtext_test.go

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions internal/tui/workspace/views/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,9 +757,9 @@ func (v *Detail) startCommentEdit() tea.Cmd {
return nil
}
c := v.data.comments[v.focusedComment]
// Fail closed on table-bearing content (see startEditBody).
if richtext.HasTableHTML(c.content) {
return workspace.SetStatus("This comment contains a table — edit it on Basecamp web", true)
// Fail closed on complex tables (see startEditBody).
if richtext.HasComplexTableHTML(c.content) {
return workspace.SetStatus("This comment contains a table too complex to edit as Markdown — edit it on Basecamp web", true)
}
v.editingComment = true
v.commentEditComposer = widget.NewComposer(v.styles,
Expand Down Expand Up @@ -1078,10 +1078,12 @@ func (v *Detail) startEditBody() tea.Cmd {
if v.data == nil {
return nil
}
// Fail closed on table-bearing content: HTMLToMarkdown has no table handling,
// so entering edit mode and resubmitting would strip the table. Block the edit.
if richtext.HasTableHTML(v.data.content) {
return workspace.SetStatus("This message contains a table — edit it on Basecamp web", true)
// Fail closed on complex tables — shapes a GFM pipe table can't
// represent (see richtext.HasComplexTableHTML): HTMLToMarkdown flattens
// them, so an edit-and-resubmit would lose structure. Simple grids
// round-trip and stay editable.
if richtext.HasComplexTableHTML(v.data.content) {
return workspace.SetStatus("This message contains a table too complex to edit as Markdown — edit it on Basecamp web", true)
}
v.editingBody = true
v.bodyEditComposer = widget.NewComposer(v.styles,
Expand Down
41 changes: 36 additions & 5 deletions internal/tui/workspace/views/detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,12 +573,18 @@ func TestDetail_CommentEdit_Ignored_WhenNoFocus(t *testing.T) {
assert.False(t, v.editingComment)
}

const tableHTML = "<figure><table><thead><tr><th>Foo</th></tr></thead>" +
// A simple grid round-trips through HTMLToMarkdown and stays editable; a
// merged-cell grid cannot be represented as a GFM pipe table, so its edits
// fail closed.
const simpleTableHTML = "<figure><table><thead><tr><th>Foo</th></tr></thead>" +
"<tbody><tr><td>Baz</td></tr></tbody></table></figure>"

func TestDetail_EditBody_BlockedForTable(t *testing.T) {
const complexTableHTML = "<figure><table><thead><tr><th>Foo</th><th>Bar</th></tr></thead>" +
"<tbody><tr><td colspan=\"2\">Baz</td></tr></tbody></table></figure>"

func TestDetail_EditBody_BlockedForComplexTable(t *testing.T) {
v := testDetailWithSession("Message", false)
v.data.content = tableHTML
v.data.content = complexTableHTML

cmd := v.startEditBody()
assert.False(t, v.editingBody, "must not enter edit mode on table content")
Expand All @@ -601,10 +607,35 @@ func TestDetail_EditBody_EntersForNonTable(t *testing.T) {
assert.NotNil(t, cmd)
}

func TestDetail_CommentEdit_BlockedForTable(t *testing.T) {
func TestDetail_EditBody_EntersForSimpleTable(t *testing.T) {
v := testDetailWithSession("Message", false)
v.data.content = simpleTableHTML

cmd := v.startEditBody()
assert.True(t, v.editingBody, "should enter edit mode on simple-table content")
require.NotNil(t, v.bodyEditComposer, "composer should be built")
assert.NotNil(t, cmd)
assert.Equal(t, "| Foo |\n| --- |\n| Baz |", v.bodyEditComposer.Value(),
"composer should hold the table as Markdown")
}

func TestDetail_CommentEdit_EntersForSimpleTable(t *testing.T) {
v := detailWithComments()
v.focusedComment = 0
v.data.comments[0].content = simpleTableHTML

cmd := v.startCommentEdit()
assert.True(t, v.editingComment, "should enter edit mode on simple-table content")
require.NotNil(t, v.commentEditComposer, "composer should be built")
assert.NotNil(t, cmd)
assert.Equal(t, "| Foo |\n| --- |\n| Baz |", v.commentEditComposer.Value(),
"composer should hold the table as Markdown")
}

func TestDetail_CommentEdit_BlockedForComplexTable(t *testing.T) {
v := detailWithComments()
v.focusedComment = 0
v.data.comments[0].content = tableHTML
v.data.comments[0].content = complexTableHTML

cmd := v.startCommentEdit()
assert.False(t, v.editingComment, "must not enter edit mode on table content")
Expand Down
10 changes: 6 additions & 4 deletions internal/tui/workspace/views/todos.go
Original file line number Diff line number Diff line change
Expand Up @@ -1042,10 +1042,12 @@ func (v *Todos) startEditDescription() tea.Cmd {
}
}

// Fail closed on table-bearing content: HTMLToMarkdown has no table handling,
// so entering edit mode and resubmitting would strip the table. Block the edit.
if richtext.HasTableHTML(description) {
return workspace.SetStatus("This to-do description contains a table — edit it on Basecamp web", true)
// Fail closed on complex tables — shapes a GFM pipe table can't
// represent (see richtext.HasComplexTableHTML): HTMLToMarkdown flattens
// them, so an edit-and-resubmit would lose structure. Simple grids
// round-trip and stay editable.
if richtext.HasComplexTableHTML(description) {
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
return workspace.SetStatus("This to-do description contains a table too complex to edit as Markdown — edit it on Basecamp web", true)
}

v.editingDesc = true
Expand Down
28 changes: 23 additions & 5 deletions internal/tui/workspace/views/todos_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -942,20 +942,23 @@ func TestTodos_BoostTarget_IncludesAccountID(t *testing.T) {
assert.Equal(t, int64(42), picker.Target.ProjectID)
}

// --- Edit description: table fail-closed guard ---
// --- Edit description: complex-table fail-closed guard ---

const todoTableHTML = "<figure><table><thead><tr><th>Foo</th></tr></thead>" +
const todoSimpleTableHTML = "<figure><table><thead><tr><th>Foo</th></tr></thead>" +
"<tbody><tr><td>Baz</td></tr></tbody></table></figure>"

func TestTodos_EditDescription_BlockedForTable(t *testing.T) {
const todoComplexTableHTML = "<figure><table><thead><tr><th>Foo</th><th>Bar</th></tr></thead>" +
"<tbody><tr><td colspan=\"2\">Baz</td></tr></tbody></table></figure>"

func TestTodos_EditDescription_BlockedForComplexTable(t *testing.T) {
v := testTodosViewWithTodos()

todos := sampleTodos()
todos[0].Description = todoTableHTML
todos[0].Description = todoComplexTableHTML
v.session.Hub().Todos(42, 10).Set(todos)

cmd := v.startEditDescription()
assert.False(t, v.editingDesc, "must not enter edit mode on table content")
assert.False(t, v.editingDesc, "must not enter edit mode on complex-table content")

require.NotNil(t, cmd, "should return a status command")
status, ok := cmd().(workspace.StatusMsg)
Expand All @@ -977,6 +980,21 @@ func TestTodos_EditDescription_EntersForNonTable(t *testing.T) {
assert.NotNil(t, cmd)
}

func TestTodos_EditDescription_EntersForSimpleTable(t *testing.T) {
v := testTodosViewWithTodos()
v.descComposer = widget.NewComposer(v.styles, widget.WithMode(widget.ComposerRich))

todos := sampleTodos()
todos[0].Description = todoSimpleTableHTML
v.session.Hub().Todos(42, 10).Set(todos)

cmd := v.startEditDescription()
assert.True(t, v.editingDesc, "should enter edit mode on simple-table content")
assert.NotNil(t, cmd)
assert.Equal(t, "| Foo |\n| --- |\n| Baz |", v.descComposer.Value(),
"composer should hold the table as Markdown, line structure intact")
}

// newTextInputWithValue creates a textinput with a preset value for testing.
func newTextInputWithValue(val string) textinput.Model {
ti := textinput.New()
Expand Down
17 changes: 11 additions & 6 deletions skills/basecamp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,17 @@ Full CLI coverage: 155 endpoints across todos, cards, messages, files, schedule,
- **`@Name` / `@First.Last`** — fuzzy name resolution (may be ambiguous)
For todos, documents, and cards, content is sent as-is — use plain text or HTML directly.

**Table boundary:** GFM tables render in message/comment bodies, but the TUI
in-place editors **refuse to open** table-bearing content (edit it on Basecamp
web, or replace the whole field via `messages update` / `comments update` /
`todos update --description`, which take fresh content and are unaffected), and
human-readable CLI/TUI **display** of such content may lose table structure —
both pending server-side Markdown support (BC3 #11986).
**Table boundary:** GFM tables round-trip: they render in message/comment
bodies, display converts them back to pipe tables, and the TUI in-place
editors open simple grids for editing. Only **complex** tables — merged
cells (colspan/rowspan), captions, extra header rows, nested tables,
attachments/images or block content inside cells, multi-paragraph or
multi-line cells, or a table inside a blockquote or list — refuse to open,
since a GFM pipe table can't represent those shapes (edit them on Basecamp
web, or replace the
whole field via `messages update` / `comments update` / `todos update
--description`, which take fresh content and are unaffected). Complex
tables still **display** best-effort, flattened to a plain grid.

**Multiline / non-ASCII content:** do not rely on bash ANSI-C quoting (`$'...\n...'`) — it is a bash/zsh extension. Under a POSIX `/bin/sh` (dash, busybox-ash, common in sandboxes) the `$` is passed through literally and posts a stray leading `$`, and `\n` stays a literal backslash-n. Pipe the content via stdin instead, using `-` as the content argument:
```bash
Expand Down