From 05e40fcc7fc3fb96de3de8b7edc7cf85c3b5af08 Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 13 Aug 2026 16:20:32 +0200 Subject: [PATCH 1/3] Render the license re-login prompt choices vertically Co-Authored-By: Claude --- internal/container/start.go | 13 ++++-- internal/container/start_test.go | 12 +++--- internal/ui/app_test.go | 45 +++++++++++++++++++++ internal/ui/components/input_prompt_test.go | 34 ++++++++++++++++ test/integration/license_test.go | 2 +- 5 files changed, 95 insertions(+), 11 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 2a6c1c9f..4a7adbad 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -1335,16 +1335,21 @@ func isDefinitiveLicenseRejection(status int) bool { // ESC declines. Ctrl+C would do too, but it also cancels the root context, and // the ErrorEvent that the decline renders then races the TUI's own quit — so the // manual recovery steps sometimes never reach the terminal (DEVX-1045). An -// advertised decline key keeps that path deterministic. +// advertised decline key keeps that path deterministic. The choices render +// vertically so both keys read as selectable actions rather than a hint tacked +// onto the end of the sentence; the prompt therefore states the reason and +// leaves the two actions to the labels, which keeps it one wrapped statement +// instead of a statement whose trailing question dangles at the wrap point. func promptRelogin(ctx context.Context, sink output.Sink, licErr *api.LicenseError) bool { responseCh := make(chan output.InputResponse, 1) sink.Emit(output.UserInputRequestEvent{ - Prompt: fmt.Sprintf("License validation failed: %s. Log in again to refresh your credentials?", licErr.Message), + Prompt: fmt.Sprintf("License validation failed: %s.", licErr.Message), Options: []output.InputOption{ - {Key: "enter", Label: "ENTER to log in again"}, - {Key: "esc", Label: "ESC to exit"}, + {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "esc", Label: "[ESC] Exit"}, }, ResponseCh: responseCh, + Vertical: true, }) select { case resp := <-responseCh: diff --git a/internal/container/start_test.go b/internal/container/start_test.go index 88365d19..bdfbec2c 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -1698,7 +1698,7 @@ func TestPromptRelogin_FoldsReasonIntoThePromptWithoutASeparateWarning(t *testin req, ok := events[0].(output.UserInputRequestEvent) require.True(t, ok, "the only event emitted must be the prompt itself") assert.Contains(t, req.Prompt, licErr.Message, "the prompt must explain why the user is being asked to log in again") - assert.Contains(t, req.Prompt, "Log in again to refresh your credentials?") + assert.Equal(t, "[ENTER] Log in again", req.Options[0].Label, "the recovery action belongs to the choice, not the prompt sentence") } // TestPromptRelogin_OffersAnAdvertisedDeclineKey covers DEVX-1045: Ctrl+C was the @@ -1728,11 +1728,11 @@ func TestPromptRelogin_OffersAnAdvertisedDeclineKey(t *testing.T) { accepted := promptRelogin(context.Background(), sink, licErr) assert.Equal(t, tc.accepted, accepted) - keys := make([]string, 0, len(req.Options)) - for _, opt := range req.Options { - keys = append(keys, opt.Key) - } - assert.Equal(t, []string{"enter", "esc"}, keys, "both the accept and the decline key must be advertised") + assert.True(t, req.Vertical, "the choices must render as vertical, selectable actions") + assert.Equal(t, []output.InputOption{ + {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "esc", Label: "[ESC] Exit"}, + }, req.Options, "both the accept and the decline key must be advertised, shortcut first") }) } } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index aa5b2d8e..cdccc0b1 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -875,6 +875,51 @@ func TestAppEnterSelectsHighlightedVerticalOption(t *testing.T) { } } +// TestAppEscResolvesVerticalDeclineOption guards the license re-login prompt's +// decline path (DEVX-1045): the vertical key handler claims Enter for the +// highlighted row, so a direct ESC press must still fall through to its option +// rather than being swallowed. +func TestAppEscResolvesVerticalDeclineOption(t *testing.T) { + t.Parallel() + + app := NewApp("dev", "", "", nil) + responseCh := make(chan output.InputResponse, 1) + + model, _ := app.Update(output.UserInputRequestEvent{ + Prompt: "License validation failed: invalid, inactive, or expired authentication token or subscription.", + Options: []output.InputOption{ + {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "esc", Label: "[ESC] Exit"}, + }, + ResponseCh: responseCh, + Vertical: true, + }) + app = model.(App) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEscape}) + app = model.(App) + if cmd == nil { + t.Fatal("expected response command when esc is pressed on a vertical prompt") + } + cmd() + + select { + case resp := <-responseCh: + if resp.SelectedKey != "esc" { + t.Fatalf("expected esc key, got %q", resp.SelectedKey) + } + if resp.Cancelled { + t.Fatal("expected esc to decline through its option, not as a cancellation") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for response on channel") + } + + if app.inputPrompt.Visible() { + t.Fatal("expected input prompt to be hidden after response") + } +} + func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) { t.Parallel() diff --git a/internal/ui/components/input_prompt_test.go b/internal/ui/components/input_prompt_test.go index 94e88bfc..774e5ba2 100644 --- a/internal/ui/components/input_prompt_test.go +++ b/internal/ui/components/input_prompt_test.go @@ -163,3 +163,37 @@ func TestInputPromptViewSlowStartChoicesAreScannable(t *testing.T) { } } } + +// TestInputPromptViewReloginChoicesAreScannable covers the license re-login +// prompt: its question is long enough to wrap, so flattening the two choices +// into a trailing hint made them read as prose. They belong on their own lines +// below the wrapped question, shortcut first. +func TestInputPromptViewReloginChoicesAreScannable(t *testing.T) { + t.Parallel() + + const width = 40 + question := "License validation failed: invalid, inactive, or expired authentication token or subscription." + p := NewInputPrompt().Show(question, []output.InputOption{ + {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "esc", Label: "[ESC] Exit"}, + }, true) + + view := p.View(width) + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + if len(lines) < 3 { + t.Fatalf("expected a wrapped question and two vertical choices, got:\n%s", view) + } + if choices := lines[len(lines)-2:]; !strings.Contains(choices[0], "[ENTER] Log in again") || + !strings.Contains(choices[1], "[ESC] Exit") { + t.Fatalf("expected each choice on its own trailing line, got:\n%s", view) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > width { + t.Errorf("line exceeds width %d (%d): %q", width, w, line) + } + } + flattened := strings.Join(strings.Fields(view), " ") + if !strings.Contains(flattened, strings.Join(strings.Fields(question), " ")) { + t.Errorf("expected the whole question to survive wrapping, got:\n%s", view) + } +} diff --git a/test/integration/license_test.go b/test/integration/license_test.go index 3b26db13..d0ae7b06 100644 --- a/test/integration/license_test.go +++ b/test/integration/license_test.go @@ -287,7 +287,7 @@ func TestLicenseRejectionEscDeclineShowsManualSteps(t *testing.T) { } }) - p.waitForOutputTimeout("ESC to exit", 60*time.Second, "the re-login prompt must be on screen, advertising the decline key") + p.waitForOutputTimeout("[ESC] Exit", 60*time.Second, "the re-login prompt must be on screen, advertising the decline key") p.write("\x1b") out, err := p.wait() From 1a4c1de465ca1afdc4ad5caa322efef128149653 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 13 Aug 2026 19:01:51 +0300 Subject: [PATCH 2/3] Use a stable re-authentication shortcut Co-Authored-By: Claude --- internal/container/start.go | 2 +- internal/container/start_test.go | 6 +-- internal/ui/app_test.go | 42 ++++++++++++++++++++- internal/ui/components/input_prompt_test.go | 4 +- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/internal/container/start.go b/internal/container/start.go index 4a7adbad..df9e77a0 100644 --- a/internal/container/start.go +++ b/internal/container/start.go @@ -1345,7 +1345,7 @@ func promptRelogin(ctx context.Context, sink output.Sink, licErr *api.LicenseErr sink.Emit(output.UserInputRequestEvent{ Prompt: fmt.Sprintf("License validation failed: %s.", licErr.Message), Options: []output.InputOption{ - {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "r", Label: "[R] Re-authenticate"}, {Key: "esc", Label: "[ESC] Exit"}, }, ResponseCh: responseCh, diff --git a/internal/container/start_test.go b/internal/container/start_test.go index bdfbec2c..73420031 100644 --- a/internal/container/start_test.go +++ b/internal/container/start_test.go @@ -1698,7 +1698,7 @@ func TestPromptRelogin_FoldsReasonIntoThePromptWithoutASeparateWarning(t *testin req, ok := events[0].(output.UserInputRequestEvent) require.True(t, ok, "the only event emitted must be the prompt itself") assert.Contains(t, req.Prompt, licErr.Message, "the prompt must explain why the user is being asked to log in again") - assert.Equal(t, "[ENTER] Log in again", req.Options[0].Label, "the recovery action belongs to the choice, not the prompt sentence") + assert.Equal(t, "[R] Re-authenticate", req.Options[0].Label, "the recovery action belongs to the choice, not the prompt sentence") } // TestPromptRelogin_OffersAnAdvertisedDeclineKey covers DEVX-1045: Ctrl+C was the @@ -1712,7 +1712,7 @@ func TestPromptRelogin_OffersAnAdvertisedDeclineKey(t *testing.T) { response output.InputResponse accepted bool }{ - {name: "enter accepts", response: output.InputResponse{SelectedKey: "enter"}, accepted: true}, + {name: "r accepts", response: output.InputResponse{SelectedKey: "r"}, accepted: true}, {name: "esc declines", response: output.InputResponse{SelectedKey: "esc"}, accepted: false}, {name: "cancel declines", response: output.InputResponse{Cancelled: true}, accepted: false}, } { @@ -1730,7 +1730,7 @@ func TestPromptRelogin_OffersAnAdvertisedDeclineKey(t *testing.T) { assert.Equal(t, tc.accepted, accepted) assert.True(t, req.Vertical, "the choices must render as vertical, selectable actions") assert.Equal(t, []output.InputOption{ - {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "r", Label: "[R] Re-authenticate"}, {Key: "esc", Label: "[ESC] Exit"}, }, req.Options, "both the accept and the decline key must be advertised, shortcut first") }) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index cdccc0b1..9d19d7c8 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -888,7 +888,7 @@ func TestAppEscResolvesVerticalDeclineOption(t *testing.T) { model, _ := app.Update(output.UserInputRequestEvent{ Prompt: "License validation failed: invalid, inactive, or expired authentication token or subscription.", Options: []output.InputOption{ - {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "r", Label: "[R] Re-authenticate"}, {Key: "esc", Label: "[ESC] Exit"}, }, ResponseCh: responseCh, @@ -920,6 +920,46 @@ func TestAppEscResolvesVerticalDeclineOption(t *testing.T) { } } +func TestAppReloginShortcutIgnoresVerticalSelection(t *testing.T) { + t.Parallel() + + app := NewApp("dev", "", "", nil) + responseCh := make(chan output.InputResponse, 1) + + model, _ := app.Update(output.UserInputRequestEvent{ + Prompt: "License validation failed: invalid, inactive, or expired authentication token or subscription.", + Options: []output.InputOption{ + {Key: "r", Label: "[R] Re-authenticate"}, + {Key: "esc", Label: "[ESC] Exit"}, + }, + ResponseCh: responseCh, + Vertical: true, + }) + app = model.(App) + + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown}) + app = model.(App) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + app = model.(App) + if cmd == nil { + t.Fatal("expected response command when r is pressed on a vertical prompt") + } + cmd() + + select { + case resp := <-responseCh: + if resp.SelectedKey != "r" { + t.Fatalf("expected r key, got %q", resp.SelectedKey) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for response on channel") + } + + if app.inputPrompt.Visible() { + t.Fatal("expected input prompt to be hidden after response") + } +} + func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) { t.Parallel() diff --git a/internal/ui/components/input_prompt_test.go b/internal/ui/components/input_prompt_test.go index 774e5ba2..7865efd6 100644 --- a/internal/ui/components/input_prompt_test.go +++ b/internal/ui/components/input_prompt_test.go @@ -174,7 +174,7 @@ func TestInputPromptViewReloginChoicesAreScannable(t *testing.T) { const width = 40 question := "License validation failed: invalid, inactive, or expired authentication token or subscription." p := NewInputPrompt().Show(question, []output.InputOption{ - {Key: "enter", Label: "[ENTER] Log in again"}, + {Key: "r", Label: "[R] Re-authenticate"}, {Key: "esc", Label: "[ESC] Exit"}, }, true) @@ -183,7 +183,7 @@ func TestInputPromptViewReloginChoicesAreScannable(t *testing.T) { if len(lines) < 3 { t.Fatalf("expected a wrapped question and two vertical choices, got:\n%s", view) } - if choices := lines[len(lines)-2:]; !strings.Contains(choices[0], "[ENTER] Log in again") || + if choices := lines[len(lines)-2:]; !strings.Contains(choices[0], "[R] Re-authenticate") || !strings.Contains(choices[1], "[ESC] Exit") { t.Fatalf("expected each choice on its own trailing line, got:\n%s", view) } From 88414e1240864b87cd0db4331b0112468de258ef Mon Sep 17 00:00:00 2001 From: Misha Tiurin Date: Thu, 13 Aug 2026 18:57:41 +0200 Subject: [PATCH 3/3] Update the relogin integration test to the new prompt shortcut Co-Authored-By: Claude --- test/integration/license_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/license_test.go b/test/integration/license_test.go index d0ae7b06..b47cb6a3 100644 --- a/test/integration/license_test.go +++ b/test/integration/license_test.go @@ -170,10 +170,10 @@ func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) { p := startLstkInPTY(t, ctx, environ, "start", "--config", configFile) - // The stale token is rejected; the re-login prompt appears. Press ENTER. - // The wait covers a cold image pull on CI runners. - p.waitForOutputTimeout("Log in again", 3*time.Minute, "the re-login prompt should appear after the license rejection") - p.write("\r") + // The stale token is rejected; the re-login prompt appears. Press R, the + // shortcut it advertises. The wait covers a cold image pull on CI runners. + p.waitForOutputTimeout("[R] Re-authenticate", 3*time.Minute, "the re-login prompt should appear after the license rejection") + p.write("r") // The login flow runs; confirm it once the completion prompt appears. p.waitForOutputTimeout("key when complete", 30*time.Second, "the login completion prompt should appear")