From abce381faa40e5932ecc71696f33d8d6cfe8ae36 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Tue, 22 Sep 2026 20:28:01 +0800 Subject: [PATCH] fix(tui): wrap the one-time setup URL so narrow terminals can't truncate it The setup URL carries a 43-char token and overruns 80 columns; the TUI renderer clipped it. Break it at the query '=' boundary (token on its own line) with a shared wrapDisplayURL helper used by both the Owner wizard and the mc-bind wizard; unit test pins the no-loss concatenation. --- cmd/felis/tui_mc_bind.go | 6 +++++- cmd/felis/tui_owner.go | 6 +++++- cmd/felis/tui_widgets.go | 27 +++++++++++++++++++++++++++ cmd/felis/tui_widgets_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 cmd/felis/tui_widgets_test.go diff --git a/cmd/felis/tui_mc_bind.go b/cmd/felis/tui_mc_bind.go index 07963fe..01c8079 100644 --- a/cmd/felis/tui_mc_bind.go +++ b/cmd/felis/tui_mc_bind.go @@ -207,7 +207,11 @@ func (m *mcBindModel) doneView() string { if box.Len() > 0 { box.WriteString("\n") } - box.WriteString(tuiLabel.Render("setup URL ") + "\n" + tuiPassword.Render(m.setupTokenURL) + "\n\n") + box.WriteString(tuiLabel.Render("setup URL ") + "\n") + for _, line := range wrapDisplayURL(m.setupTokenURL, 70) { + box.WriteString(tuiPassword.Render(line) + "\n") + } + box.WriteString("\n") box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup.\nIt is shown only once.")) } if m.auditWarning != "" { diff --git a/cmd/felis/tui_owner.go b/cmd/felis/tui_owner.go index a01c63f..2ec4019 100644 --- a/cmd/felis/tui_owner.go +++ b/cmd/felis/tui_owner.go @@ -420,7 +420,11 @@ func (m *ownerModel) doneView() string { var box strings.Builder box.WriteString(tuiLabel.Render("username ") + m.username + "\n") if m.setupTokenURL != "" { - box.WriteString("\n" + tuiLabel.Render("setup URL ") + "\n" + tuiPassword.Render(m.setupTokenURL) + "\n\n") + box.WriteString("\n" + tuiLabel.Render("setup URL ") + "\n") + for _, line := range wrapDisplayURL(m.setupTokenURL, 70) { + box.WriteString(tuiPassword.Render(line) + "\n") + } + box.WriteString("\n") box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup. It is shown only once.")) } if m.auditWarning != "" { diff --git a/cmd/felis/tui_widgets.go b/cmd/felis/tui_widgets.go index 4e9b61a..bb72b60 100644 --- a/cmd/felis/tui_widgets.go +++ b/cmd/felis/tui_widgets.go @@ -29,6 +29,33 @@ func tuiSeparator() string { return tuiHint.Render(strings.Repeat("─", 70)) } +// wrapDisplayURL breaks a long URL into lines no wider than width so the TUI +// renderer never truncates it on a narrow terminal — the one-time setup URL +// carries a 43-char token and overruns 80 columns. It prefers breaking right +// after a '=' or '/' inside the window (the token then lands on its own line) +// and hard-wraps only when no boundary is available. Lines concatenate back to +// the original string. +func wrapDisplayURL(u string, width int) []string { + if width <= 0 { + width = 70 + } + var lines []string + for len(u) > width { + cut := width + if i := strings.LastIndexByte(u[:width], '='); i >= 0 && i >= width/2 { + cut = i + 1 + } else if i := strings.LastIndexByte(u[:width], '/'); i >= 0 && i >= width/2 { + cut = i + 1 + } + lines = append(lines, u[:cut]) + u = u[cut:] + } + if u != "" { + lines = append(lines, u) + } + return lines +} + // tuiStepRail renders a breadcrumb of wizard stages. Steps before `current` // render as done, `current` is highlighted, and later steps are dimmed. func tuiStepRail(steps []string, current int) string { diff --git a/cmd/felis/tui_widgets_test.go b/cmd/felis/tui_widgets_test.go new file mode 100644 index 0000000..862ab54 --- /dev/null +++ b/cmd/felis/tui_widgets_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "strings" + "testing" +) + +func TestWrapDisplayURL(t *testing.T) { + u := "https://op.console.example.net/setup?token=" + strings.Repeat("A", 43) + lines := wrapDisplayURL(u, 70) + if got := strings.Join(lines, ""); got != u { + t.Fatalf("concatenated lines = %q, want the original URL back", got) + } + for i, l := range lines { + if len(l) > 70 { + t.Errorf("line %d is %d cols wide: %q", i, len(l), l) + } + } + if len(lines) < 2 || !strings.HasSuffix(lines[0], "token=") { + t.Fatalf("want the first line to end at the 'token=' boundary, got %q", lines) + } + short := "https://a/b" + if got := wrapDisplayURL(short, 70); len(got) != 1 || got[0] != short { + t.Errorf("short URL should pass through unsplit, got %q", got) + } +}