Skip to content

TUI Calendar: open a read-only event card on Enter - #419

Open
albertreig wants to merge 6 commits into
basecamp:mainfrom
albertreig:feat/calendar-event-detail-card
Open

albertreig wants to merge 6 commits into
basecamp:mainfrom
albertreig:feat/calendar-event-detail-card

Conversation

@albertreig

@albertreig albertreig commented Sep 9, 2026

Copy link
Copy Markdown

What

In the calendar's Day and Week views, Enter did nothing on a highlighted event; inside a Year cell it did nothing either. Meanwhile the content help bar advertised enter open the whole time — the generic rowContent binding from updateHelpBindings, 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 with e.

Enter now opens a read-only detail card over the grid — the same shape as Contacts, where Enter views a contact and e edits it.

╭──────────────────────────────────────────────╮
│  Roadmap review                              │
│                                              │
│  Thursday, August 20 · 2:00pm–3:00pm         │
│  Repeats every week                          │
│                                              │
│  Calendar  Design Team                       │
│  Location  Sala 2                            │
│  Link      https://meet.example.com/roadmap  │
│  Guests    ana@example.com, luis@example.com │
│                                              │
│  Notes                                       │
│  Bring the September report                  │
╰──────────────────────────────────────────────╯

How

  • The card is built from the selected Recording alone — the grid read already carries Notes, Location, Link and Attendees (kept on the model for exactly this reason), so there is no extra request.
  • From the card: o opens the link through viewContext.openAttachment (the same xdg-open / open / Windows handler attachments use), e closes the card and opens the edit form on the same event, esc/q closes it, and the arrows / page keys scroll the notes.
  • calendarView.detail joins CapturingInput(), so the model routes every key to the card (it handles esc/q itself, not via CancelPendingDetail) and the help bar shows o / e / esc instead of the misleading generic enter open.
  • o only opens http/https links. 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 — the card shows those but never hands them to the OS launcher, and does not offer o 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 with no cell open still steps into the cell; only once inYearCell does Enter open the selected event.

Changes

File
internal/tui/event_detail.go new — the card: layout, the when/where line, repeat-frequency label, notes wrapping, help bindings
internal/tui/calendar.go detail field; openEventDetail / openEventLink / calendarName; hooks in handleContentKey, handleArrowKey, View, HelpBindings, CapturingInput, Resize, Restyle, CancelPendingDetail
internal/tui/calendar_test.go tests: Enter opens the card (holds every key, esc/q close it), o opens an http link, o refuses a non-web link, a model-level esc regression test, e swaps in the edit form, Enter opens the card inside a Year cell
docs/tui.md Calendar key reference note

Testing

go build ./..., go vet ./..., golangci-lint run (v2.11.1) and go test ./... all pass.

Fixes #418

🤖 Generated with Claude Code

https://claude.ai/code/session_01C6Y2KeiHB6QNWmHdgqbzD1

Copilot AI balanced review requested due to automatic review settings September 9, 2026 04:21
@albertreig
albertreig requested a review from a team as a code owner September 9, 2026 04:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to 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.

Comment thread internal/tui/calendar.go Outdated
Comment thread internal/tui/event_detail.go
Comment thread internal/tui/calendar.go
Comment thread internal/tui/event_detail.go Outdated
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 Enter path is not exercised by the added tests: TestEnterOpensTheReadOnlyEventCard uses 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

  • wrapText only breaks at whitespace, so a single long URL or other unbroken token in notes remains wider than modalContentWidth despite 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/https scheme, including https:foo or https:// with no host, and sends it to the OS launcher. That is not a valid web address and is inconsistent with the form's eventLinkProblem, which requires a host; reject hostless URLs (and compare schemes case-insensitively) before offering or invoking o.
	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.

Comment thread internal/tui/event_detail.go Outdated
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, so modalFrame becomes wider than the canvas and the centered card is clipped instead of remaining usable. Wrap the rendered when() 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/fitGraphemes helpers 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.

Comment thread internal/tui/styles.go Outdated
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 startEventForm is attempted. When the selected event is on a read-only/external calendar and there are no fileable calendars, startEventForm returns an error without creating a form, so e leaves 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.

Comment thread internal/tui/styles.go Outdated
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Three moderate review issues remain unresolved.

Review details

Suppressed comments (3)

internal/tui/event_detail.go:134

  • Recurring can be true for a schedule kind that is not in the current presets; the existing form explicitly covers every_third_thursday by 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.Host includes the port, so a value such as https://:443 has a non-empty Host here even though its hostname is empty and it still gets handed to the OS launcher. Check parsed.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; fitGraphemes already 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²).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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, modalContentWidth is 4, but a row still renders Location plus 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, fitGraphemes returns an empty string and this break leaves the entire remaining token in w. A value such as 🎉abc on 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 wWidth and 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.
@albertreig
albertreig requested a balanced review from Copilot September 14, 2026 15:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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

  • newEventDetail copies v.use24Hour into the card, but that copy is never refreshed. Identity and recording loads run independently, so a reader can open the card before identityLoadedMsg arrives; 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 contentWidth is 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 "", but terminal.SanitizeLine preserves ordinary spaces. A server-supplied location or calendar name containing only spaces therefore produces an empty-looking row; because wrapText returns an unwrapped all-whitespace string when strings.Fields is 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • use24Hour is 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; when identityLoadedMsg later 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 Notes heading 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")
Comment thread internal/tui/styles.go
Comment on lines +194 to +195
if w := displayWidth(l); w > innerWidth {
innerWidth = w
@albertreig

Copy link
Copy Markdown
Author

I've addressed all of Copilot's substantive findings across five review rounds:

  • esc/q closing: handled in handleContentKey before the viewport, with model-level regression test.
  • URL validation: openableLink now requires a non-empty Hostname() (not just Host), rejects hostless URIs like https:foo or https://:443, and compares schemes case-insensitively.
  • Modal overflow: all card rows (when line, label+value, notes) wrap to modalContentWidth. On very narrow terminals where the label column alone would exceed the content width, the layout switches to stacked (label truncated, value indented).
  • wrapText: hard-wraps at grapheme boundaries using fitGraphemes; the inner loop is linear (subtracts chunk width instead of rescanning); leading graphemes wider than maxWidth are emitted as-is and the loop advances past them.
  • All-day date formatting: same-day ends and exclusive midnight boundaries now show as a single day, consistent with the grid.
  • Unknown repeat kind: shows Repeats on a schedule instead of silently dropping the row.
  • e transition: the card is only cleared after confirming v.eventForm != nil, so a failed edit (no fileable calendars) leaves the read-only card visible.
  • errorView: uses displayWidth instead of len() for cell-accurate sizing.
  • Week-view Enter: covered by TestEnterOpensTheEventCardOnWeekView.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TUI Calendar: Enter does nothing on a highlighted event (Day/Week), though the help bar shows "enter: open"

3 participants