TUI Calendar: open a read-only event card on Enter - #419
albertreig wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Closing shortcuts are currently ineffective, and event metadata, time-zone labeling, and opened URL schemes need safer handling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a read-only calendar event card for Day, Week, and Year views.
Changes:
- Opens event details with Enter.
- Supports link opening, editing, closing, and note scrolling.
- Documents and tests the new interactions.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
File summaries
| File | Description |
|---|---|
internal/tui/event_detail.go |
Implements the event card. |
internal/tui/calendar.go |
Integrates card interaction and lifecycle. |
internal/tui/calendar_test.go |
Tests opening, editing, and links. |
docs/tui.md |
Documents calendar controls. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
In the calendar's Day and Week views Enter did nothing on a highlighted event, and inside a Year cell it did nothing either, while the content help bar advertised "enter open" the whole time (the generic rowContent binding, live in Mail and dead here). The only way to see an event's notes, location, link or guests was to open the edit form with `e`. Enter now opens a read-only detail card over the grid, the way Contacts opens a contact on Enter and leaves `e` for editing. The card is built from the selected Recording alone -- the grid read already carries Notes, Location, Link and Attendees -- so nothing is fetched. From the card `o` opens the link, `e` swaps in the edit form on the same event, esc/q closes it, and the arrows and page keys scroll the notes. The card is an inputCapturer, so it handles esc itself and the help bar shows its keys instead of the generic "enter open". Two safeguards on what the card shows and does: - `o` only hands an http/https link to the OS launcher. Event links are server data and the edit form accepts any URI with a host, so a shared event could carry a file:// path or an application scheme; those are shown on the card but not opened, and `o` is not offered for them. - The when-and-where line shows the reader's local clock, the same conversion Recording.Starts/Ends and the grid make, rather than labelling a converted time with the event's original zone. The calendar name is sanitized like every other view of server metadata. Year view keeps its two stages: Enter steps into a cell, and only once inside does it open the selected event. Fixes basecamp#418 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6Y2KeiHB6QNWmHdgqbzD1
6cda55d to
cae22f1
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate findings affect modal sizing, URL validation, and all-day date formatting, and Week-view Enter lacks test coverage.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
internal/tui/calendar.go:1198
- The new Week-view
Enterpath is not exercised by the added tests:TestEnterOpensTheReadOnlyEventCarduses the default Day view, while the other new test covers only Year. Since Week has a separate branch here and is one of the requested entry points, add a Week-view assertion that Enter opens the selected recording's card.
case "enter":
v.openEventDetail()
return nil, true
internal/tui/event_detail.go:215
wrapTextonly breaks at whitespace, so a single long URL or other unbroken token in notes remains wider thanmodalContentWidthdespite this wrapper. The card can consequently grow past the terminal and clip the notes; hard-wrap oversized words using display width (or otherwise constrain them) before putting them in the viewport.
lines = append(lines, wrapText(paragraph, width)...)
internal/tui/event_detail.go:104
- This validation accepts any URI with an
http/httpsscheme, includinghttps:fooorhttps://with no host, and sends it to the OS launcher. That is not a valid web address and is inconsistent with the form'seventLinkProblem, which requires a host; reject hostless URLs (and compare schemes case-insensitively) before offering or invokingo.
parsed, err := url.Parse(link)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", false
internal/tui/event_detail.go:162
- The all-day branch treats any later end timestamp as a date range, but the calendar's own grouping treats same-day ends as a single day (
calendar_views.go:132-145). Thus an event returned as Aug 21 00:00–23:59:59 is shown as “August 21 – August 21”, and an exclusive midnight end such as Aug 21 00:00 is shown as including Aug 21. Collapse same-day ends and subtract an exact-midnight boundary before formatting; add tests for both representations.
if d.event.AllDay {
if !ends.IsZero() && ends.After(starts) {
return starts.Format("Monday, January 2") + " – " + ends.Format("Monday, January 2") + " · all day"
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
…eek-view test - openableLink: require non-empty host and compare scheme case-insensitively, so hostless URIs (https:roadmap, https://) and uppercase schemes are handled. - content() rows: wrap long values (URL, attendee list) to available modal width so no row makes the modal wider than the terminal. - wrapText: hard-wrap tokens that exceed maxWidth so a long URL in notes cannot overflow the card viewport. - when() all-day: collapse same-day ends and subtract exclusive midnight boundary so Aug 21 00:00–23:59 and Aug 21 00:00–Aug 22 00:00 both show as a single day. - Add TestEnterOpensTheEventCardOnWeekView, TestAllDayEvent*, TestWrapText* and extend TestEventDetailOpensOnlyHostedWebLinks with new cases.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved comments remain around narrow-terminal wrapping and UTF-8-safe text handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
internal/tui/event_detail.go:124
- The when line is not wrapped, while the card's content width is
width-6. On a narrow terminal (for example, a 40-column window), a full weekday/month plus time range is wider than the modal content, somodalFramebecomes wider than the canvas and the centered card is clipped instead of remaining usable. Wrap the renderedwhen()text to the same content width as the other card rows.
b.WriteString(d.styles.entryDate.Render(d.when()) + "\n")
internal/tui/event_detail.go:239
- This hard-wraps by byte offset, not by UTF-8/grapheme boundaries or terminal cells. A long non-ASCII event link, guest name, or note (for example repeated emoji) can be split inside a rune and render replacement characters, corrupting the value; shorter wide-text words are measured incorrectly too. Use the existing
displayWidth/fitGraphemeshelpers and advance by the selected chunk's byte length.
}
// wrapParagraphs wraps each line to width while keeping the blank lines between paragraphs,
// which wrapText on its own drops — notes are written with those breaks and read worse without.
func wrapParagraphs(text string, width int) []string {
var lines []string
for _, paragraph := range strings.Split(text, "\n") {
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
…l when() line - wrapText: use displayWidth/fitGraphemes instead of byte slicing so hard-wrap never splits inside a rune or emoji sequence, and wide characters (emoji, CJK) are measured in terminal cells rather than bytes. - content(): wrap the when() line to modalContentWidth so a long weekday+time range does not overflow the modal on narrow terminals. - Add TestWrapTextSplitsAtGraphemeBoundaries and TestEventCardWhenLineWrapsOnNarrowTerminal.
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate issues remain in error sizing and failed edit transitions.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/tui/calendar.go:1064
- The card is cleared before
startEventFormis attempted. When the selected event is on a read-only/external calendar and there are no fileable calendars,startEventFormreturns an error without creating a form, soeleaves the user with neither the detail card nor an edit form. Only clear the card after confirming that the form was created, so a failed edit transition leaves the read-only details available.
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
…rorView - calendar.go: only clear the detail card after confirming v.eventForm was created; if startEventForm finds no fileable calendars it returns an error notice without setting v.eventForm, so a failed edit transition now leaves the read-only card visible instead of leaving the user with nothing. - styles.go: use displayWidth instead of len() in errorView so the box width is measured in terminal cells, consistent with wrapText's new contract; a line of wide characters (emoji, CJK) no longer expands the error box past the terminal.
There was a problem hiding this comment.
🔵 Needs a closer look
Three moderate review issues remain unresolved.
Review details
Suppressed comments (3)
internal/tui/event_detail.go:134
Recurringcan be true for a schedule kind that is not in the current presets; the existing form explicitly coversevery_third_thursdayby displaying that it keeps an unknown schedule (internal/tui/event_form_test.go:742-744). With this guard, the detail card drops the entire repeat row for such events, making them appear non-recurring. Keep a fallback row for unknown kinds (for example,Repeats on a schedule) instead of omitting it.
if label := repeatFrequencyLabel(d.event.RepeatKind); d.event.Recurring && label != "" {
b.WriteString(styleMuted.Render("Repeats "+label) + "\n")
internal/tui/event_detail.go:112
url.URL.Hostincludes the port, so a value such ashttps://:443has a non-emptyHosthere even though its hostname is empty and it still gets handed to the OS launcher. Checkparsed.Hostname()(and reject an empty result) to make the advertised non-empty-host validation effective.
if parsed.Host == "" {
internal/tui/styles.go:237
- This hard-wrap loop recalculates
displayWidth(w)over the entire remaining token on every chunk. A server-supplied notes or URL value with N unbroken cells therefore takes quadratic work while opening, resizing, or restyling the card;fitGraphemesalready advances by a bounded chunk, so compute the word width once and iterate over chunks to keep this linear.
for displayWidth(w) > maxWidth {
if line != "" {
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
…apText - event_detail.go: show 'Repeats on a schedule' when Recurring is true but the kind is not a known preset, matching the form's own treatment of unknown schedules (event_form_test.go:742-744). - event_detail.go: use parsed.Hostname() instead of parsed.Host so that a port-only authority like 'https://:443' is correctly rejected. - styles.go: compute displayWidth(w) once per word before the hard-wrap loop and update it after each chunk, keeping the inner loop O(n) instead of O(n²).
There was a problem hiding this comment.
🔵 Needs a closer look
Three moderate issues remain in narrow-terminal layout and text-wrapping behavior.
Review details
Suppressed comments (3)
internal/tui/event_detail.go:148
- The fixed 8-cell label column can make the card exceed the terminal on narrow widths: with a 10-column terminal,
modalContentWidthis 4, but a row still rendersLocationplus two spaces and at least one value cell (11 columns). This defeats the wrapping guarantee and can push the modal off-screen; switch to a stacked/truncated label layout when the content width cannot accommodate the label column.
// The label column is 8 chars + 2 spaces of padding; what remains is for the value.
labelWidth := 10
valueWidth := max(contentWidth-labelWidth, 1)
internal/tui/styles.go:247
- When the first grapheme of a token is wider than
maxWidth,fitGraphemesreturns an empty string and thisbreakleaves the entire remaining token inw. A value such as🎉abcon a very narrow terminal is therefore emitted as one over-wide line instead of wrapping the suffix after the unavoidable wide grapheme; advance past that grapheme before continuing the loop.
chunk := fitGraphemes(w, maxWidth)
if chunk == "" {
break // single cluster wider than maxWidth — emit it whole to avoid infinite loop
}
internal/tui/styles.go:250
- The suffix width is recomputed from scratch after every hard-wrapped chunk, so a single unbroken server-provided note or URL makes this loop quadratic in the token length and can stall the TUI for large values. Keep the initial
wWidthand subtract the emitted chunk's width instead of scanning the shrinking suffix each iteration.
lines = append(lines, chunk)
w = w[len(chunk):]
wWidth = displayWidth(w)
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
…advance, linear wrapText - event_detail.go: switch to stacked label layout (label truncated to contentWidth, value indented by 2) when contentWidth <= labelWidth, so no row overflows the modal on very narrow terminals. - styles.go: when the leading grapheme of a word is wider than maxWidth, emit it as-is and advance past it (instead of breaking) so the suffix is still wrapped. - styles.go: subtract the emitted chunk width from wWidth instead of scanning the remaining suffix with displayWidth, keeping the hard-wrap loop strictly linear.
There was a problem hiding this comment.
🔵 Needs a closer look
Three moderate issues remain around preference synchronization, narrow layouts, and whitespace-only fields.
Review details
Suppressed comments (3)
internal/tui/calendar.go:1342
newEventDetailcopiesv.use24Hourinto the card, but that copy is never refreshed. Identity and recording loads run independently, so a reader can open the card beforeidentityLoadedMsgarrives; when a 24-hour preference is then applied to the grid, this already-open card still formats its times in 12-hour form. Keep the card synchronized when the identity preference changes (or have it read the view's current setting).
v.detail = newEventDetail(event, v.calendarName(event.CalendarID), v.use24Hour, v.vc.styles, v.vc.width, v.vc.height)
internal/tui/event_detail.go:167
- The stacked layout still prefixes every value with two spaces even when
contentWidthis 1. In a sufficiently narrow terminal,modalContentWidth(width)deliberately returns 1, so these lines are at least two cells wide and the card violates the width guarantee stated above. Bound the indentation by the available width and wrap against the remaining cells.
for _, valueLine := range wrapText(row[1], max(contentWidth-2, 1)) {
fmt.Fprintf(&b, " %s\n", valueLine)
internal/tui/event_detail.go:156
- The empty-field check only matches
"", butterminal.SanitizeLinepreserves ordinary spaces. A server-supplied location or calendar name containing only spaces therefore produces an empty-looking row; becausewrapTextreturns an unwrapped all-whitespace string whenstrings.Fieldsis empty, that row can also exceed the modal width. Treat whitespace-only values as empty here.
for _, row := range rows {
if row[1] == "" {
continue
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved issues remain with time-format synchronization and narrow-terminal width handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
internal/tui/calendar.go:1342
use24Houris copied into the card only when it opens, but the calendar starts its identity read concurrently with the calendar data read. If recordings arrive first, the card can open with the default 12-hour format; whenidentityLoadedMsglater updates the grid to the user's 24-hour preference, this card is not updated. Keep the open detail's time-format setting synchronized when identity loads (and re-render it).
v.detail = newEventDetail(event, v.calendarName(event.CalendarID), v.use24Hour, v.vc.styles, v.vc.width, v.vc.height)
internal/tui/event_detail.go:183
- The narrow-terminal layout wraps field labels, but this
Notesheading is written at its full five-cell width. With the new stacked layout at an outer width of 10 (contentWidth == 4), any event that has notes produces a line wider than the modal content and breaks the width guarantee tested below; wrap or otherwise fit the heading as well.
b.WriteString("\n" + d.styles.entryFrom.Render("Notes") + "\n")
for _, line := range wrapParagraphs(terminal.Sanitize(notes), modalContentWidth(d.width)) {
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
| if label == "" { | ||
| label = "on a schedule" // unknown repeat kind — still recurring, just not a named preset | ||
| } | ||
| b.WriteString(styleMuted.Render("Repeats "+label) + "\n") |
| if w := displayWidth(l); w > innerWidth { | ||
| innerWidth = w |
|
I've addressed all of Copilot's substantive findings across five review rounds:
The remaining Copilot comments are about increasingly narrow edge cases (sub-10-column terminals, single graphemes wider than the modal). Happy to keep iterating if you want, but I'd rather have a human set of eyes on this at this point. Ready for your review, @jeremy. |
What
In the calendar's Day and Week views,
Enterdid nothing on a highlighted event; inside a Year cell it did nothing either. Meanwhile the content help bar advertisedenter openthe whole time — the genericrowContentbinding fromupdateHelpBindings, which is live in Mail and dead in the calendar. The only way to see an event's notes, location, link or guests was to open the edit form withe.Enternow opens a read-only detail card over the grid — the same shape as Contacts, whereEnterviews a contact andeedits it.How
Recordingalone — the grid read already carriesNotes,Location,LinkandAttendees(kept on the model for exactly this reason), so there is no extra request.oopens the link throughviewContext.openAttachment(the samexdg-open/open/ Windows handler attachments use),ecloses the card and opens the edit form on the same event,esc/qcloses it, and the arrows / page keys scroll the notes.calendarView.detailjoinsCapturingInput(), so the model routes every key to the card (it handlesesc/qitself, not viaCancelPendingDetail) and the help bar showso/e/escinstead of the misleading genericenter open.oonly openshttp/httpslinks. Event links are server data and the edit form accepts any URI with a host, so a shared event could carry afile://path or an application scheme — the card shows those but never hands them to the OS launcher, and does not offerofor them.Recording.Starts/Endsand the grid make), rather than labelling a converted time with the event's original zone. The calendar name is sanitized like every other view of server metadata.Enterwith no cell open still steps into the cell; only onceinYearCelldoesEnteropen the selected event.Changes
internal/tui/event_detail.gointernal/tui/calendar.godetailfield;openEventDetail/openEventLink/calendarName; hooks inhandleContentKey,handleArrowKey,View,HelpBindings,CapturingInput,Resize,Restyle,CancelPendingDetailinternal/tui/calendar_test.goesc/qclose it),oopens an http link,orefuses a non-web link, a model-levelescregression test,eswaps in the edit form, Enter opens the card inside a Year celldocs/tui.mdTesting
go build ./...,go vet ./...,golangci-lint run(v2.11.1) andgo test ./...all pass.Fixes #418
🤖 Generated with Claude Code
https://claude.ai/code/session_01C6Y2KeiHB6QNWmHdgqbzD1