Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/welcome/mod.rs
T
ZacharyZhang-NY 8179438278 feat(providers): add GitHub Copilot subscription OAuth (device flow + copilot-token re-mint)
28th platform `github-copilot` (uses_oauth, ChatCompletions wire). Two-stage auth:
RFC-8628 GitHub device flow (client Iv1.b507a08c87ecfe98, scope read:user, errors
in a 200 body) mints the DURABLE github token; a GET api.github.com/copilot_internal/
v2/token exchange re-mints the SHORT-LIVED copilot token. Persisted as key=copilot
token, refresh_token=github token, expires_at=copilot expiry; the "refresh" is a
copilot-token re-mint (not a refresh_token grant), dispatched via
OAuthTokenBody::GithubCopilotExchange in the generic refresher.

VS Code editor-identity headers on /models + /chat/completions, gated on
SamplerConfig.github_copilot / PlatformId::sends_copilot_editor_headers() so every
other ChatCompletions provider stays byte-identical. Live /models filtered
(parse_github_copilot_listing) to the openai-completions-served models: keep iff
model_picker_enabled && policy.state!="disabled" && tool_calls!=false AND not a
claude-4.x/5.x (messages) or gpt-5/oswe/mai- (responses-only) id — those need
per-model wire routing (documented debt), excluded rather than mis-routed.

Inherits the leak-safe pooled routing (scope oauth/github-copilot); its bearer/
refresh/api_key never touch the Kimi token (regression test added). Fail-fast on
an out-of-range copilot expires_at (would otherwise silently 401 mid-session).
Adversarial security review: GO, no CRITICAL/HIGH. Known limitation: Pi's
per-model policy-enablement POST is not ported (documented in AGENTS.md).
2026-07-22 04:21:02 -04:00

3020 lines
107 KiB
Rust

//! Welcome screen — the first thing users see.
//!
//! Layout (top to bottom):
//! - Top margin row (always preserved)
//! - Top bar: repo_root:branch (left), version (right)
//! - Vertically centered content: logo → gap → menu → gap → prompt
//! - Bottom margin
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
use agent_client_protocol as acp;
use crate::app::app_view::{
AuthMode, AuthState, PendingMenuItem, SessionPickerEntry, TrustState, pending_menu_items,
};
use crate::startup::StartupWarning;
use crate::theme::Theme;
use crate::views::prompt_widget::{PromptFlag, PromptInfo, PromptWidget};
mod hero_box;
pub(crate) mod logo;
mod menu;
mod prompt;
mod top_bar;
pub(crate) use logo::shimmer_frame;
use logo::{logo_line_count, render_logo};
use menu::render_menu;
pub(crate) use top_bar::location_line_at;
use top_bar::render_top_bar;
/// True for VS Code and xterm.js embeds (VS Code-family IDEs and Zed) where
/// quit is `Ctrl+D` (canonical: [`TerminalName::is_vscode_family`]).
fn welcome_in_vscode_family() -> bool {
crate::terminal::terminal_context().brand.is_vscode_family()
}
/// Build the quit hint spans used in Authenticating sub-screens.
fn quit_hint_spans(theme: &Theme) -> Vec<Span<'static>> {
let key = if welcome_in_vscode_family() {
"ctrl+d"
} else {
"ctrl+q"
};
vec![
Span::styled(
key,
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" quit", Style::default().fg(theme.gray)),
]
}
/// Horizontal margin (left and right) in normal mode.
const H_MARGIN: u16 = 2;
/// Horizontal margin in compact mode.
const H_MARGIN_COMPACT: u16 = 1;
/// Minimum width for the menu section so it doesn't resize when the import row toggles.
/// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41.
/// Bumped to 51 for comfortable breathing room.
const MENU_MIN_WIDTH: u16 = 51;
/// Whether the welcome prompt is currently focused (accepting text input).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WelcomePromptFocus {
#[default]
Unfocused,
Focused,
}
/// Result of rendering the welcome screen.
#[derive(Default)]
pub struct WelcomeRenderResult {
/// Cursor position (if the prompt wants a visible cursor).
pub cursor_pos: Option<(u16, u16)>,
/// Terminal image/cursor escapes paired with their ownership transition.
pub post_flush_escapes: Option<crate::terminal::overlay::PostFlush>,
/// Hit-test rects for each menu item (for click/hover).
pub menu_rects: Vec<Rect>,
/// Hit-test rect for the prompt input area (for click to start session).
pub prompt_rect: Option<Rect>,
/// Hit-test rect for the import-claude banner (for click to open import modal).
pub import_banner_rect: Option<Rect>,
/// Hit areas from the session picker (for mouse hit-testing).
pub session_picker_hit_areas: Option<crate::views::picker::PickerHitAreas>,
/// Hit-test rect for the auth copy line (click-to-copy during Authenticating).
pub auth_url_rect: Option<Rect>,
/// Hit-test rect for the "show full URL" fallback link.
pub auth_fallback_rect: Option<Rect>,
}
use hero_box::HERO_BOX_MIN_WIDTH;
/// Prompt input height (shared across hero and stacked layout paths).
const PROMPT_HEIGHT: u16 = 3;
/// Gap between prompt and version line.
const VERSION_GAP: u16 = 1;
/// Computed areas for the welcome screen vertical layout.
pub(super) struct WelcomeLayout {
pub(super) logo: Rect,
pub(super) error: Rect,
pub(super) menu: Rect,
pub(super) tip: Rect,
pub(super) prompt: Rect,
pub(super) version: Rect,
// Hero box sub-rects (all zero when hero box is inactive).
pub(super) hero_box: Rect,
pub(super) hero_logo: Rect,
pub(super) hero_version: Rect,
pub(super) hero_subtitle: Rect,
pub(super) hero_menu: Rect,
}
/// Inputs to [`WelcomeLayout::compute`] / [`WelcomeLayout::compute_stacked`].
///
/// Bundled (and `Default`-able) so call sites name each field — in particular
/// the two distinct compaction flags can't be silently transposed.
#[derive(Default)]
struct WelcomeLayoutInput {
content_area: Rect,
/// Error/warning row height; 0 when there's nothing to show.
error_height: u16,
menu_height: u16,
tip_height: u16,
/// Vertical compaction (session picker visible): skip the logo.
compact: bool,
/// Horizontal-inset compaction (appearance setting) for the stacked slot.
prompt_compact: bool,
}
impl WelcomeLayout {
/// Whether the hero box (side-by-side logo + menu inside a border) is active.
pub(super) fn has_hero_box(&self) -> bool {
self.hero_box.width > 0 && self.hero_box.height > 0
}
pub(super) fn fixed_below(tip_height: u16) -> u16 {
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1
}
/// Compute the welcome screen layout, allowing the wide hero-box variant.
fn compute(input: WelcomeLayoutInput) -> Self {
Self::compute_inner(input, true)
}
/// Compute the welcome screen layout, forced to the stacked variant.
///
/// The blocked screens (login, ZDR gate) render through
/// `render_welcome_blocked`, which only paints the stacked `logo`/`menu`
/// rects. The hero-box layout zeroes those, so the blocked path must stay
/// stacked regardless of terminal size.
fn compute_stacked(input: WelcomeLayoutInput) -> Self {
Self::compute_inner(input, false)
}
/// Compute the welcome screen layout.
///
/// Picks hero vs stacked. `allow_hero_box` gates the wide variant;
/// stacked-only callers pass `false`.
fn compute_inner(input: WelcomeLayoutInput, allow_hero_box: bool) -> Self {
let WelcomeLayoutInput {
content_area,
error_height,
menu_height,
tip_height,
compact,
prompt_compact,
} = input;
let _ = prompt_compact;
let zero = Rect::default();
let use_hero_box = allow_hero_box
&& !compact
&& content_area.width >= HERO_BOX_MIN_WIDTH
&& menu_height > 0
&& content_area.height
>= hero_box::min_content_height(error_height, menu_height, tip_height);
if use_hero_box {
return hero_box::compute_hero_box(content_area, error_height, menu_height, tip_height);
}
// Stacked layout: skip the logo in compact mode (the session picker
// needs the space); otherwise pick small/full/none by height.
let logo_rows = if compact {
0
} else {
logo_line_count(content_area.height)
};
let gap_after_logo = if error_height > 0 { 1 } else { 0 };
let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
let fixed_below = Self::fixed_below(tip_height);
let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo
// Compute top_pad using the *default* menu height (4 items = 7 rows) so
// the logo position stays constant regardless of picker/focus state.
let top_pad = if compact {
0
} else {
let default_menu_height = 4u16;
let remaining = content_area.height.saturating_sub(fixed_above);
remaining
.saturating_sub(default_menu_height)
.saturating_sub(fixed_below)
/ 3
};
let logo_gap = 1u16;
let flex_gap = 1u16;
let [_, logo, _, _, error, menu, _, tip, _, prompt, _, version] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_rows),
Constraint::Length(logo_gap), // gap after logo
Constraint::Length(gap_after_logo),
Constraint::Length(error_height),
Constraint::Length(menu_height),
Constraint::Min(flex_gap),
Constraint::Length(tip_height),
Constraint::Length(tip_gap),
Constraint::Length(PROMPT_HEIGHT),
Constraint::Length(VERSION_GAP),
Constraint::Length(1), // version
])
.areas(content_area);
Self {
logo,
error,
menu,
tip,
prompt,
version,
hero_box: zero,
hero_logo: zero,
hero_version: zero,
hero_subtitle: zero,
hero_menu: zero,
}
}
}
/// Controls what the version badge renders.
pub(super) enum VersionBadgeMode {
/// Full badge: team | tier | api_key | **Kigi** VERSION+channel (right-aligned).
Full,
/// Hero footer: team | api_key | Kigi [channel] (right-aligned, gray).
HeroFooter,
/// Hero inline: **Kigi** VERSION (left-aligned).
HeroInline,
}
pub(super) fn render_version_badge(
version_rect: Rect,
buf: &mut Buffer,
theme: &Theme,
team_name: Option<&str>,
h_margin: u16,
is_api_key_auth: bool,
mode: VersionBadgeMode,
) {
let version_area = Rect {
width: version_rect.width.saturating_sub(h_margin),
..version_rect
};
let sep = Span::styled(
" \u{2502} ",
Style::default().fg(theme.gray).add_modifier(Modifier::DIM),
);
let mut spans = Vec::new();
let (show_team, show_api_key, align) = match &mode {
VersionBadgeMode::Full => (true, true, Alignment::Right),
VersionBadgeMode::HeroFooter => (true, true, Alignment::Right),
VersionBadgeMode::HeroInline => (false, false, Alignment::Left),
};
if show_team && let Some(team) = team_name {
spans.push(Span::styled(team, Style::default().fg(theme.gray)));
spans.push(sep.clone());
}
if show_api_key && is_api_key_auth {
spans.push(Span::styled(
"Logged in with API key",
Style::default().fg(theme.gray),
));
spans.push(sep);
}
let channel = kigi_update::channel_label();
match &mode {
VersionBadgeMode::Full => {
spans.push(Span::styled(
"Kigi ",
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{}{}", kigi_version::VERSION, channel),
Style::default().fg(theme.gray),
));
}
VersionBadgeMode::HeroFooter => {
let channel_display = if channel.is_empty() {
"Kigi"
} else {
channel.trim()
};
spans.push(Span::styled(
channel_display,
Style::default().fg(theme.gray),
));
}
VersionBadgeMode::HeroInline => {
spans.push(Span::styled(
"Kigi ",
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
kigi_version::VERSION,
Style::default().fg(theme.gray),
));
}
}
let version_line = Line::from(spans).alignment(align);
Paragraph::new(version_line).render(version_area, buf);
}
/// Render the prompt box and version line (shared across welcome states).
///
/// When `skip_version` is true the version badge is not rendered (it was
/// already drawn inside the hero box).
#[allow(clippy::too_many_arguments)]
fn render_prompt_and_version(
layout: &WelcomeLayout,
content_width: u16,
buf: &mut Buffer,
theme: &Theme,
focus: WelcomePromptFocus,
prompt: &mut PromptWidget,
info: &PromptInfo<'_>,
tip: Option<&str>,
team_name: Option<&str>,
h_margin: u16,
compact: bool,
pending_hint: Option<crate::views::shortcuts_bar::PendingHint>,
is_api_key_auth: bool,
skip_version: bool,
) -> (
Option<(u16, u16)>,
Option<crate::terminal::overlay::PostFlush>,
) {
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(layout.prompt);
if let Some(tip_text) = tip
&& layout.tip.height > 0
{
let [_, tip_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(layout.tip);
let inset = prompt::prompt_inset(compact);
let tip_inset = Rect {
x: tip_centered.x + inset,
y: tip_centered.y,
width: tip_centered.width.saturating_sub(inset * 2),
height: tip_centered.height,
};
crate::tips::render::render_tip(tip_inset, buf, tip_text);
}
let prompt_result =
prompt::render_prompt(prompt_centered, buf, focus, prompt, info, 2, 2, compact);
if let Some(pending) = &pending_hint {
let key_style = Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD);
let action_style = Style::default().fg(theme.gray);
let key_text = pending.shortcut.display();
let label = format!("press again to {}", pending.label);
let line = Line::from(vec![
Span::styled(format!(" {key_text}"), key_style),
Span::styled(":", action_style),
Span::styled(label, action_style),
]);
buf.set_line(
layout.version.x,
layout.version.y,
&line,
layout.version.width,
);
} else if !skip_version {
render_version_badge(
layout.version,
buf,
theme,
team_name,
h_margin,
is_api_key_auth,
VersionBadgeMode::Full,
);
} else {
render_version_badge(
layout.version,
buf,
theme,
team_name,
h_margin,
is_api_key_auth,
VersionBadgeMode::HeroFooter,
);
}
prompt_result
}
/// All display state for rendering the welcome screen.
pub struct WelcomeRenderParams<'a> {
pub prompt_focus: WelcomePromptFocus,
pub auth_state: &'a AuthState,
/// Folder-trust state. When `Pending` (auth done, access granted), the
/// welcome screen renders the trust question instead of the normal prompt.
pub trust_state: &'a TrustState,
/// Shell-advertised auth methods — the login picker lists the interactive
/// ones (see [`pending_menu_items`]).
pub auth_methods: &'a [acp::AuthMethod],
pub login_label: Option<&'a str>,
pub auth_code_input: &'a str,
pub clipboard_copied: bool,
pub show_raw_url: bool,
pub tip: Option<&'a str>,
pub model_name: &'a str,
pub flags: &'a [PromptFlag<'a>],
pub selected: Option<usize>,
pub has_claude_import: bool,
pub mouse_pos: Option<(u16, u16)>,
pub session_picker: Option<&'a [SessionPickerEntry]>,
pub session_picker_loading: bool,
pub compact: bool,
pub pending_hint: Option<crate::views::shortcuts_bar::PendingHint>,
pub startup_warnings: &'a [StartupWarning],
pub pending_update_version: Option<&'a str>,
/// Recent foreign session offered on ctrl+u, suppressed by a pending update.
pub foreign_resume_hint: Option<&'a kigi_workspace::foreign_sessions::RecentForeignSession>,
pub is_api_key_auth: bool,
pub session_picker_content_results:
Option<&'a [kigi_shell::extensions::session_search::SearchSessionHit]>,
pub session_picker_content_loading: bool,
/// The query the picker entries were server-fetched with (see
/// [`crate::views::session_picker::effective_filter_query`]).
pub session_picker_entries_query: Option<&'a str>,
pub welcome_tick: u64,
pub session_picker_grouped: bool,
/// Source filter (local/remote/all) for the session picker.
pub session_picker_source_filter: crate::views::session_picker::SourceFilter,
/// Process-wide `--chat`: the picker lists backend conversations only, so
/// the Local/Remote source filter and local deep search are hidden.
pub chat_mode: bool,
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
/// the current repo's session group to the top of the picker.
pub cwd: &'a std::path::Path,
}
/// Render the welcome screen.
pub fn render_welcome(
area: Rect,
buf: &mut Buffer,
params: &WelcomeRenderParams<'_>,
prompt: &mut PromptWidget,
session_picker_state: &mut crate::views::picker::PickerState,
) -> WelcomeRenderResult {
let theme = Theme::current();
let h_margin = if params.compact {
H_MARGIN_COMPACT
} else {
H_MARGIN
};
let v_margin = 1u16;
buf.set_style(area, Style::default().bg(theme.bg_base));
// Top bar is always 1 row.
let [_, top_bar_area, content_area, _] = Layout::vertical([
Constraint::Length(v_margin),
Constraint::Length(1),
Constraint::Min(10),
Constraint::Length(v_margin),
])
.areas(area);
let top_bar_inner = Rect {
x: top_bar_area.x + h_margin,
y: top_bar_area.y,
width: top_bar_area.width.saturating_sub(h_margin * 2),
height: 1,
};
render_top_bar(top_bar_inner, buf, &theme);
let mut result = match params.auth_state {
AuthState::Pending { error } => {
// Login picker: one row per interactive method + Quit.
let items: Vec<PendingMenuItem> =
pending_menu_items(params.auth_methods, params.login_label);
let menu: Vec<(&str, &str)> = items
.iter()
.enumerate()
.map(|(i, item)| (item.shortcut(i), item.label()))
.collect();
let msg = error.as_deref().map(|e| (e, theme.accent_error));
let info = PromptInfo {
model_name: params.model_name,
flags: params.flags,
multiline: false,
};
let (menu_rects, post_flush_escapes) = render_welcome_blocked(
content_area,
buf,
msg,
&menu,
params.selected,
Some((prompt, &info)),
h_margin,
params.compact,
);
WelcomeRenderResult {
cursor_pos: None,
post_flush_escapes,
menu_rects,
prompt_rect: None,
session_picker_hit_areas: None,
import_banner_rect: None,
auth_url_rect: None,
auth_fallback_rect: None,
}
}
AuthState::Authenticating { auth_url, mode, .. } => {
let llc = logo_line_count(content_area.height);
let (url_rect, fallback_rect) = render_welcome_authenticating(
content_area,
buf,
&theme,
llc,
auth_url.as_deref(),
*mode,
params.auth_code_input,
params.clipboard_copied,
params.show_raw_url,
);
WelcomeRenderResult {
cursor_pos: None,
post_flush_escapes: None,
menu_rects: vec![],
prompt_rect: None,
session_picker_hit_areas: None,
import_banner_rect: None,
auth_url_rect: url_rect,
auth_fallback_rect: fallback_rect,
}
}
// Folder-trust question: shown after auth, before any session is
// created, when the cwd has untrusted repo-local config. Mirrors the
// Pending login screen. The `if let` destructure makes the
// `Pending`-only render structurally exhaustive (no `unreachable!`).
AuthState::Done => {
if let TrustState::Pending { workspace } = params.trust_state {
render_welcome_trust(
content_area,
buf,
&theme,
workspace,
params.selected,
h_margin,
params.compact,
)
} else {
render_welcome_done(
content_area,
buf,
&theme,
params,
prompt,
session_picker_state,
h_margin,
)
}
}
};
if result.post_flush_escapes.is_none() {
result.post_flush_escapes = crate::terminal::overlay::clear().map(Into::into);
}
result
}
/// Render a blocked welcome screen: logo + optional message + menu + version.
///
/// Used for both the login screen (Pending) and the ZDR gate. The layout is:
/// Logo
/// {message}
/// Menu items
/// {prompt} (optional)
/// Version badge
#[allow(clippy::too_many_arguments)]
fn render_welcome_blocked(
content_area: Rect,
buf: &mut Buffer,
message: Option<(&str, ratatui::style::Color)>,
menu_items: &[(&str, &str)],
selected: Option<usize>,
prompt: Option<(&mut PromptWidget, &PromptInfo<'_>)>,
h_margin: u16,
compact: bool,
) -> (Vec<Rect>, Option<crate::terminal::overlay::PostFlush>) {
let theme = Theme::current();
let msg_height = if message.is_some() { 2u16 } else { 0u16 };
let menu_height = menu_items.len() as u16;
// Force the stacked layout: this renderer only paints the stacked
// logo/menu rects, which the hero-box layout would leave empty.
let layout = WelcomeLayout::compute_stacked(WelcomeLayoutInput {
content_area,
error_height: msg_height,
menu_height,
compact,
prompt_compact: compact,
..Default::default()
});
render_logo(layout.logo, buf, &theme, content_area.height);
if let Some((text, color)) = message {
let line =
Line::from(Span::styled(text, Style::default().fg(color))).alignment(Alignment::Center);
Paragraph::new(line).render(layout.error, buf);
}
// Inset the menu the same as the input bar / post-auth menu so the actions
// keep side spacing instead of touching the window edge on narrow terminals.
let menu_area = inset_horizontal(layout.menu, prompt::prompt_inset(compact));
let menu_rects = render_menu(menu_area, buf, &theme, menu_items, selected, None, 0);
let post_flush_escapes = if let Some((prompt_widget, info)) = prompt {
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(layout.prompt);
prompt::render_prompt(
prompt_centered,
buf,
WelcomePromptFocus::Unfocused,
prompt_widget,
info,
2,
2,
compact,
)
.1
} else {
None
};
render_version_badge(
layout.version,
buf,
&theme,
None,
h_margin,
false,
VersionBadgeMode::Full,
);
(menu_rects, post_flush_escapes)
}
/// Render the folder-trust question. Mirrors [`render_welcome_blocked`]'s
/// stacked layout (logo + message + menu + version badge), but the message is a
/// multi-line block showing the workspace path and the warning that Kigi
/// may run or modify contents in this directory (a security risk). The y/N
/// answer is handled by the welcome input interceptor, so this only paints;
/// `menu_rects` are returned for parity with the other welcome arms.
fn render_welcome_trust(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
workspace: &std::path::Path,
selected: Option<usize>,
h_margin: u16,
compact: bool,
) -> WelcomeRenderResult {
let menu_items = [("y", "Yes, proceed"), ("n", "No, quit")];
let lines = vec![
Line::from(Span::styled(
"Do you trust the contents of this directory?",
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center),
Line::from(Span::styled(
workspace.display().to_string(),
Style::default().fg(theme.accent_user),
))
.alignment(Alignment::Center),
Line::default(),
// Two lines so the warning never clips at narrow / compact widths
// (a single ~78-char line would truncate "...posing security risks").
Line::from(Span::styled(
"Kigi may run or modify contents in this directory,",
Style::default().fg(theme.gray),
))
.alignment(Alignment::Center),
Line::from(Span::styled(
"posing security risks.",
Style::default().fg(theme.gray),
))
.alignment(Alignment::Center),
// Spacer between the warning and the y/n menu.
Line::default(),
];
let msg_height = lines.len() as u16;
let menu_height = menu_items.len() as u16;
let layout = WelcomeLayout::compute_stacked(WelcomeLayoutInput {
content_area,
error_height: msg_height,
menu_height,
compact,
prompt_compact: compact,
..Default::default()
});
render_logo(layout.logo, buf, theme, content_area.height);
Paragraph::new(lines).render(layout.error, buf);
let menu_area = inset_horizontal(layout.menu, prompt::prompt_inset(compact));
let menu_rects = render_menu(menu_area, buf, theme, &menu_items, selected, None, 0);
render_version_badge(
layout.version,
buf,
theme,
None,
h_margin,
false,
VersionBadgeMode::Full,
);
// Only `menu_rects` are meaningful here; the rest are absent (no prompt,
// picker, auth/gate links) -- `Default` keeps this honest without a 13-field
// all-`None` literal.
WelcomeRenderResult {
menu_rects,
..Default::default()
}
}
/// Header text shared by Loopback and Command auth modes.
const AUTH_HEADER: &str = "A browser window will open for authentication.";
/// Header text for the device-flow auth mode.
const DEVICE_AUTH_HEADER: &str = "Approve in your browser to finish signing in.";
/// Caption beneath the device code.
const DEVICE_CODE_CAPTION: &str = "Make sure your browser shows this code.";
/// Extract `user_code` from a device verification URL (`None` if absent or
/// malformed). Shown on-screen so the user can confirm it matches the browser
/// before approving (anti-phishing).
fn extract_user_code(url: &str) -> Option<&str> {
let code = url
.split('?')
.nth(1)?
.split('&')
.find_map(|kv| kv.strip_prefix("user_code="))?;
let valid = !code.is_empty() && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
valid.then_some(code)
}
/// Clickable copy prompt shared by Loopback and Command auth modes.
const AUTH_COPY_PREFIX: &str = "If it doesn't open, click ";
const AUTH_COPY_HERE: &str = "here";
const AUTH_COPY_SUFFIX: &str = " to copy.";
/// Build the "click here to copy" line with "here" underlined in accent color.
fn auth_copy_line(theme: &Theme) -> Line<'static> {
Line::from(vec![
Span::styled(AUTH_COPY_PREFIX, Style::default().fg(theme.gray_bright)),
Span::styled(
AUTH_COPY_HERE,
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::UNDERLINED),
),
Span::styled(AUTH_COPY_SUFFIX, Style::default().fg(theme.gray_bright)),
])
.alignment(Alignment::Center)
}
/// Number of physical rows the header + blank occupy before the copy line.
fn auth_copy_preceding_rows(header: &str, inner_width: u16) -> u16 {
let header_rows = (header.len() as u16).div_ceil(inner_width);
header_rows + 1 // header + blank
}
/// Number of physical rows the copy line occupies when wrapped.
fn auth_copy_line_rows(inner_width: u16) -> u16 {
let copy_len = AUTH_COPY_PREFIX.len() + AUTH_COPY_HERE.len() + AUTH_COPY_SUFFIX.len();
(copy_len as u16).div_ceil(inner_width)
}
const AUTH_FALLBACK_TEXT: &str = "Copying not working? Click here to show full URL.";
/// Build the fallback "show full URL" link line.
fn auth_fallback_line(theme: &Theme) -> Line<'static> {
Line::from(Span::styled(
AUTH_FALLBACK_TEXT,
Style::default()
.fg(theme.gray)
.add_modifier(Modifier::UNDERLINED),
))
.alignment(Alignment::Center)
}
/// Push the shared copy-prompt block: the "click here to copy" line, a "copied!"
/// slot (kept blank when not copied so the height is stable), and the
/// show-full-URL fallback link.
fn push_auth_copy_block(lines: &mut Vec<Line<'static>>, theme: &Theme, clipboard_copied: bool) {
lines.push(Line::default());
lines.push(auth_copy_line(theme));
lines.push(Line::default());
lines.push(if clipboard_copied {
Line::from(Span::styled("copied!", Style::default().fg(theme.gray)))
.alignment(Alignment::Center)
} else {
Line::default()
});
lines.push(Line::default());
lines.push(auth_fallback_line(theme));
}
/// Rows occupied by [`push_auth_copy_block`].
fn auth_copy_block_rows(inner_width: u16) -> u16 {
auth_copy_line_rows(inner_width) + 5
}
/// Click hit-rects for the copy line and fallback link. `header`'s wrapped row
/// count sets the copy line's vertical offset.
fn auth_hit_rects(
msg_area: Rect,
h_pad: u16,
inner_width: u16,
header: &str,
preceding_extra: u16,
) -> (Option<Rect>, Option<Rect>) {
let preceding = auth_copy_preceding_rows(header, inner_width) + preceding_extra;
let copy_rows = auth_copy_line_rows(inner_width);
let copy_rect = Rect {
x: msg_area.x + h_pad,
y: msg_area.y + preceding,
width: inner_width,
height: copy_rows,
};
// fallback line is after: copy_rows + blank + copied_slot + blank
let fallback_y = msg_area.y + preceding + copy_rows + 3;
let fb_rect = Rect {
x: msg_area.x + h_pad,
y: fallback_y,
width: inner_width,
height: 1,
};
(Some(copy_rect), Some(fb_rect))
}
/// Render the "raw URL" mode: shows the full URL with mouse capture disabled
/// so the user can select and copy it natively.
fn render_raw_url_mode(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
top_pad: u16,
logo_line_count: u16,
auth_url: Option<&str>,
) -> (Option<Rect>, Option<Rect>) {
// Use full terminal width for the URL so the terminal wraps it
// naturally without inserting spaces (important for copy-paste).
let full_width = content_area.width.max(1);
let url_lines = auth_url
.map(|u| (u.len() as u16).div_ceil(full_width))
.unwrap_or(0);
let msg_height = 1 + 1 + url_lines; // hint + blank + URL
let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
Constraint::Length(2),
Constraint::Length(msg_height),
Constraint::Min(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
// Render hint above the URL.
let hint = Line::from(Span::styled(
"Select the URL below with your mouse and copy manually.",
Style::default().fg(theme.gray),
))
.alignment(Alignment::Center);
Paragraph::new(hint).render(
Rect {
height: 1,
..msg_area
},
buf,
);
// Write the URL directly to the buffer character-by-character so the
// terminal wraps naturally at the screen edge. Ratatui's Paragraph
// wrap inserts spaces at break points which corrupts the URL on copy.
//
// When the URL fits on a single line, center it to match the rest of the
// screen. When it's longer, keep it flush-left at the full terminal width
// so the natural wrap preserves copy-paste (centering a wrapped URL would
// inject leading spaces into the selection).
if let Some(url) = auth_url {
let url_style = Style::default().fg(theme.accent_user);
let url_y = msg_area.y + 2; // after hint + blank
// Control characters are skipped below to prevent terminal escape
// injection, so measure the URL without them.
let url_len = url.chars().filter(|c| !c.is_control()).count() as u16;
let x_offset = if url_len <= full_width {
(full_width - url_len) / 2
} else {
0
};
let buf_area = buf.area();
let buf_max_col = buf_area.x + buf_area.width;
let buf_max_row = buf_area.y + buf_area.height;
for (i, ch) in url.chars().filter(|c| !c.is_control()).enumerate() {
let col = msg_area.x + x_offset + (i as u16) % full_width;
let row = url_y + (i as u16) / full_width;
if row >= msg_area.y + msg_area.height {
break;
}
// Guard against OOB access during resize races.
if col >= buf_max_col || row >= buf_max_row {
continue;
}
buf[(col, row)].set_char(ch).set_style(url_style);
}
}
let hint_spans = vec![
Span::styled(
"ctrl+q",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" go back", Style::default().fg(theme.gray)),
];
let hints = Line::from(hint_spans).alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
(None, None) // no click rects — mouse capture is disabled
}
/// Which "browser opened, now waiting" arm to render; owns the header,
/// waiting caption, and (for `Device`) the device-code derivation.
#[derive(Clone, Copy)]
enum BrowserStatusKind {
/// External auth provider opened its own browser.
Command,
/// RFC 8628 device flow — also shows the device code.
Device,
}
/// Render a "browser opened, now waiting" auth arm (Command + Device).
///
/// Shared status layout: logo, then a centered block of header, optional device
/// code + caption, optional copy/fallback links (when there's a URL), and the
/// waiting caption; finally quit hints.
#[allow(clippy::too_many_arguments)]
fn render_browser_status_arm(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
top_pad: u16,
logo_line_count: u16,
auth_url: Option<&str>,
show_raw_url: bool,
clipboard_copied: bool,
kind: BrowserStatusKind,
) -> (Option<Rect>, Option<Rect>) {
let h_pad: u16 = content_area.width / 6;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
if show_raw_url {
return render_raw_url_mode(content_area, buf, theme, top_pad, logo_line_count, auth_url);
}
// Device also parses the user code from the verification URL.
let (header, waiting_text, user_code) = match kind {
BrowserStatusKind::Command => (AUTH_HEADER, "Waiting for login to complete...", None),
BrowserStatusKind::Device => (
DEVICE_AUTH_HEADER,
"Waiting for approval...",
auth_url.and_then(extract_user_code),
),
};
let header_rows = (header.len() as u16).div_ceil(inner_width);
let code_extra = if user_code.is_some() {
let caption_rows = (DEVICE_CODE_CAPTION.len() as u16).div_ceil(inner_width);
1 + 1 + 1 + caption_rows // blank + code + blank + caption
} else {
0
};
let copy_extra = if auth_url.is_some() {
auth_copy_block_rows(inner_width)
} else {
0
};
let msg_height = header_rows + code_extra + copy_extra + 1 + 1; // blank + waiting
let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
Constraint::Length(2), // gap
Constraint::Length(msg_height), // status message
Constraint::Min(1), // gap
Constraint::Length(1), // hints
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(header, Style::default().fg(theme.gray_bright)))
.alignment(Alignment::Center),
];
if let Some(code) = user_code {
lines.push(Line::default());
lines.push(
Line::from(Span::styled(
code.to_owned(),
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
);
lines.push(Line::default());
lines.push(
Line::from(Span::styled(
DEVICE_CODE_CAPTION,
Style::default().fg(theme.gray),
))
.alignment(Alignment::Center),
);
}
if auth_url.is_some() {
push_auth_copy_block(&mut lines, theme, clipboard_copied);
}
lines.push(Line::default());
lines.push(
Line::from(Span::styled(waiting_text, Style::default().fg(theme.gray)))
.alignment(Alignment::Center),
);
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_area, buf);
let (click_rect, fallback_rect) = if auth_url.is_some() {
auth_hit_rects(msg_area, h_pad, inner_width, header, code_extra)
} else {
(None, None)
};
let hints = Line::from(quit_hint_spans(theme)).alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
(click_rect, fallback_rect)
}
/// Render the welcome screen during authentication (Authenticating state).
#[allow(clippy::too_many_arguments)]
fn render_welcome_authenticating(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
logo_line_count: u16,
auth_url: Option<&str>,
mode: AuthMode,
auth_code_input: &str,
clipboard_copied: bool,
show_raw_url: bool,
) -> (Option<Rect>, Option<Rect>) {
let top_pad = content_area.height.saturating_sub(logo_line_count) / 10;
match mode {
AuthMode::Loopback => {
// Manual token paste: show copy prompt + input box
let h_pad: u16 = content_area.width / 6;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
if show_raw_url {
return render_raw_url_mode(
content_area,
buf,
theme,
top_pad,
logo_line_count,
auth_url,
);
}
let msg_height = if auth_url.is_some() {
let header_rows = (AUTH_HEADER.len() as u16).div_ceil(inner_width);
header_rows + auth_copy_block_rows(inner_width)
} else {
1u16
};
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
Constraint::Length(1), // gap
Constraint::Length(msg_height), // instruction + copy prompt
Constraint::Min(1), // gap
Constraint::Length(5), // prompt box
Constraint::Length(1), // gap
Constraint::Length(1), // hints
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
// Instruction text
let mut lines: Vec<Line> = Vec::new();
if auth_url.is_some() {
lines.push(
Line::from(Span::styled(
AUTH_HEADER,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center),
);
push_auth_copy_block(&mut lines, theme, clipboard_copied);
} else {
lines.push(
Line::from(Span::styled(
"Waiting for auth URL...",
Style::default().fg(theme.gray),
))
.alignment(Alignment::Center),
);
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_area, buf);
let (click_rect, fallback_rect) = if auth_url.is_some() {
auth_hit_rects(msg_area, h_pad, inner_width, AUTH_HEADER, 0)
} else {
(None, None)
};
// Prompt box with token input
let prompt_width = content_area.width;
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(prompt_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(prompt_area);
render_auth_input_box(
prompt_centered,
buf,
theme,
auth_code_input,
"Paste your token here...",
);
// Hints
let mut hint_spans = vec![
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" submit ", Style::default().fg(theme.gray)),
];
hint_spans.extend(quit_hint_spans(theme));
let hints = Line::from(hint_spans).alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
(click_rect, fallback_rect)
}
AuthMode::ApiKeyEntry(target) => {
// Platform API-key paste box: instruction + input + hints. No
// auth-URL machinery — the key comes from the platform console.
let h_pad: u16 = content_area.width / 6;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
let instruction = format!(
"Paste your {} API key (from {})",
target.vendor(),
target.console_host()
);
let msg_height = (instruction.len() as u16).div_ceil(inner_width);
let [_, logo_area, _, msg_area, _, prompt_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
Constraint::Length(1), // gap
Constraint::Length(msg_height), // instruction
Constraint::Min(1), // gap
Constraint::Length(5), // prompt box
Constraint::Length(1), // gap
Constraint::Length(1), // hints
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let msg = Line::from(Span::styled(
instruction,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center);
Paragraph::new(msg)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_area, buf);
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(prompt_area);
render_auth_input_box(
prompt_centered,
buf,
theme,
auth_code_input,
"Paste your API key here...",
);
let hints = Line::from(vec![
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" submit ", Style::default().fg(theme.gray)),
Span::styled(
"esc",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" back", Style::default().fg(theme.gray)),
])
.alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
(None, None)
}
AuthMode::Command => render_browser_status_arm(
content_area,
buf,
theme,
top_pad,
logo_line_count,
auth_url,
show_raw_url,
clipboard_copied,
BrowserStatusKind::Command,
),
AuthMode::Device => render_browser_status_arm(
content_area,
buf,
theme,
top_pad,
logo_line_count,
auth_url,
show_raw_url,
clipboard_copied,
BrowserStatusKind::Device,
),
AuthMode::Pending => {
// Connecting: status text
let [_, logo_area, _, msg_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let msg = Line::from(Span::styled(
"Connecting...",
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center);
Paragraph::new(msg).render(msg_area, buf);
let hints = Line::from(quit_hint_spans(theme)).alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
(None, None)
}
}
}
/// Shrink a rect by `inset` columns on the left and right (clamped at 0).
fn inset_horizontal(rect: Rect, inset: u16) -> Rect {
Rect {
x: rect.x + inset,
width: rect.width.saturating_sub(inset * 2),
..rect
}
}
/// Render the normal welcome screen (Done state -- already authenticated).
fn render_welcome_done(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
p: &WelcomeRenderParams<'_>,
prompt: &mut PromptWidget,
session_picker_state: &mut crate::views::picker::PickerState,
h_margin: u16,
) -> WelcomeRenderResult {
let show_picker = p.session_picker.is_some() || p.session_picker_loading;
// Only use compact layout when the session picker is visible — it needs
// the logo/centering space for its list. Plain compact mode keeps the
// normal welcome layout.
let welcome_compact = show_picker;
let in_vscode_family = welcome_in_vscode_family();
// Startup-warning hint height (multi-line aware).
let hint_height = p.startup_warnings.first().map_or(0u16, |w| {
let msg_lines = w.message.lines().count() as u16;
let action_line = if w.action.is_some() { 1 } else { 0 };
msg_lines + action_line + 1 // +1 for buffer spacing
});
let has_update_tip = p.pending_update_version.is_some();
let has_resume_tip = !has_update_tip && p.foreign_resume_hint.is_some();
let tip_height = if !show_picker {
if has_update_tip || has_resume_tip {
1u16 // update/resume tips are short, always 1 row
} else if let Some(tip_text) = p.tip {
let inset = prompt::prompt_inset(welcome_compact);
let tip_width = content_area.width.saturating_sub(inset * 2);
crate::tips::render::tip_height(tip_width, tip_text)
} else {
0
}
} else {
0
};
let owned_menu;
let menu_items: &[(&str, &str)] = {
let (key_w, key_s, key_q, key_i_with_x) = (
"ctrl+w",
"ctrl+s",
if in_vscode_family { "ctrl+d" } else { "ctrl+q" },
"ctrl+i [x]",
);
// Insert the import row at the top when there are pending `.claude/`
// settings to import — it's the most actionable item right now.
let mut items: Vec<(&str, &str)> = Vec::with_capacity(4);
if p.has_claude_import {
// The trailing "[x]" is a clickable dismiss affordance — the
// welcome screen mouse handler treats clicks on the rightmost
// 3 cells of this row as dismiss instead of open. Keyboard:
// ctrl-shift-i. The key string is right-aligned by render_menu,
// so [x] sits at the very end of the row.
items.push((key_i_with_x, "Import Claude settings"));
}
items.push((key_w, "New worktree"));
items.push((key_s, "Resume session"));
items.push((key_q, "Quit"));
owned_menu = items;
owned_menu.as_slice()
};
let menu_height = if show_picker {
0
} else {
menu_items.len() as u16
};
// Session picker height: 1 row per entry (no dividers), scrollable.
let picker_count = p.session_picker.map_or(0, |s| s.len());
let picker_height = if show_picker {
if p.session_picker_loading {
1
} else {
(picker_count as u16).min(15) + 3 // +3 for title + search + gap
}
} else {
0
};
let content_height = menu_height + picker_height;
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area,
error_height: hint_height,
menu_height: content_height,
tip_height,
compact: welcome_compact,
prompt_compact: p.compact,
});
// Render startup warning in the error area (same slot as auth errors).
let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings);
let (menu_rects, picker_close_button) = if show_picker {
// Use the full area since logo/menu are hidden and shortcuts
// are now rendered inside the picker content area.
let picker_area = Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: content_area.height,
};
let hit_areas = render_session_picker(
picker_area,
buf,
theme,
&mut SessionPickerRenderCtx {
state: session_picker_state,
sessions: p.session_picker,
loading: p.session_picker_loading,
pending_hint: p.pending_hint,
shortcuts_area: None,
content_results: p.session_picker_content_results,
content_loading: p.session_picker_content_loading,
entries_query: p.session_picker_entries_query,
tick: p.welcome_tick,
grouped: p.session_picker_grouped,
source_filter: p.session_picker_source_filter,
chat_mode: p.chat_mode,
cwd: p.cwd,
},
);
(vec![], Some(hit_areas))
} else if layout.has_hero_box() {
// Wide layout: render bordered hero box with logo left, version + menu right.
let menu_rects =
hero_box::render_hero_box(&layout, buf, theme, menu_items, p.selected, p.mouse_pos);
(menu_rects, None)
} else {
// Narrow layout: stacked logo above, menu below. Inset the menu the
// same as the input bar (`prompt_inset`) so it keeps side spacing
// instead of touching the window edge on narrow terminals.
render_logo(layout.logo, buf, theme, content_area.height);
let menu_area = inset_horizontal(layout.menu, prompt::prompt_inset(p.compact));
(
render_menu(
menu_area,
buf,
theme,
menu_items,
p.selected,
p.mouse_pos,
MENU_MIN_WIDTH,
),
None,
)
};
// Skip the prompt input when picker is visible to save space;
// shortcuts are rendered inside the picker content area.
let (cursor_pos, post_flush_escapes) = if show_picker {
(None, None)
} else {
// When a background update is available, show the update
// notification in the tip area instead of the random tip.
// Render the update notification with accent styling when present.
if let Some(ver) = p.pending_update_version
&& layout.tip.height > 0
{
let [_, tip_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(layout.tip);
let inset = prompt::prompt_inset(p.compact);
let tip_inset = Rect {
x: tip_centered.x + inset,
y: tip_centered.y,
width: tip_centered.width.saturating_sub(inset * 2),
height: tip_centered.height,
};
let key_name = "ctrl+u";
let line = Line::from(vec![
Span::styled(
"Update: ",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("v{ver} available \u{2014} press {key_name} to restart"),
Style::default().fg(theme.accent_user),
),
]);
Paragraph::new(line)
.style(Style::default().bg(theme.bg_base))
.render(tip_inset, buf);
}
// Recent foreign session: offer a one-click resume in the tip area
// (only when no update is pending — the update shares ctrl+u and wins).
if p.pending_update_version.is_none()
&& let Some(hint) = p.foreign_resume_hint
&& layout.tip.height > 0
{
let [_, tip_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(layout.tip);
let inset = prompt::prompt_inset(p.compact);
let tip_inset = Rect {
x: tip_centered.x + inset,
y: tip_centered.y,
width: tip_centered.width.saturating_sub(inset * 2),
height: tip_centered.height,
};
let mins = hint.age.as_secs() / 60;
let when = if mins == 0 {
"moments ago".to_string()
} else {
format!("{mins}m ago")
};
let accent = Style::default().fg(theme.accent_user);
let accent_bold = accent.add_modifier(Modifier::BOLD);
let tool = crate::app::foreign_tool_display_label(hint.tool);
let line = Line::from(vec![
Span::styled("Coming from ", accent),
Span::styled(tool, accent_bold),
Span::styled(format!("? Resume your session from {when} using "), accent),
Span::styled("ctrl+u", accent_bold),
]);
Paragraph::new(line)
.style(Style::default().bg(theme.bg_base))
.render(tip_inset, buf);
}
let usage_info = PromptInfo {
model_name: p.model_name,
flags: p.flags,
multiline: false,
};
render_prompt_and_version(
&layout,
content_area.width,
buf,
theme,
p.prompt_focus,
prompt,
&usage_info,
if p.pending_update_version.is_some() || p.foreign_resume_hint.is_some() {
// Update/resume tip already rendered above with custom styling.
None
} else {
p.tip
},
None,
h_margin,
p.compact,
p.pending_hint,
p.is_api_key_auth,
layout.has_hero_box(),
)
};
WelcomeRenderResult {
cursor_pos,
post_flush_escapes,
menu_rects,
prompt_rect: if show_picker {
None
} else {
Some(layout.prompt)
},
session_picker_hit_areas: picker_close_button,
import_banner_rect,
auth_url_rect: None,
auth_fallback_rect: None,
}
}
/// Context for session picker rendering.
pub(crate) struct SessionPickerRenderCtx<'a> {
pub(crate) state: &'a mut crate::views::picker::PickerState,
pub(crate) sessions: Option<&'a [SessionPickerEntry]>,
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
/// the current repo's group to the top.
pub(crate) cwd: &'a std::path::Path,
pub(crate) loading: bool,
pub(crate) pending_hint: Option<crate::views::shortcuts_bar::PendingHint>,
pub(crate) shortcuts_area: Option<Rect>,
pub(crate) content_results:
Option<&'a [kigi_shell::extensions::session_search::SearchSessionHit]>,
pub(crate) content_loading: bool,
/// The query `sessions` were server-fetched with (see
/// [`crate::views::session_picker::effective_filter_query`]).
pub(crate) entries_query: Option<&'a str>,
pub(crate) tick: u64,
/// When true, entries are grouped by `repo_name` with non-selectable headers.
pub(crate) grouped: bool,
/// Source filter (local/remote/all) for filtering session entries.
pub(crate) source_filter: crate::views::session_picker::SourceFilter,
/// Process-wide `--chat`: hides the source-filter chip and the
/// deep-search/filter footer hints (see `WelcomeRenderParams::chat_mode`).
pub(crate) chat_mode: bool,
}
/// Render the session picker list on the welcome screen.
///
/// Builds `PickerEntry` items from `SessionPickerEntry` data and delegates to
/// `render_picker`. Returns `PickerHitAreas` for mouse hit-testing.
pub(crate) fn render_session_picker(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
ctx: &mut SessionPickerRenderCtx<'_>,
) -> crate::views::picker::PickerHitAreas {
use crate::views::picker::{self, PickerConfig, PickerEntry, PickerField, PickerRow};
use crate::views::session_picker::{
SessionEntryData, build_grouped_picker_entries, build_session_entry_data,
};
let entries_data = match ctx.sessions {
Some(s) => s,
None => &[],
};
// Filter entries by query and source (shared helper). The same effective
// query must drive filtering AND the content header/rows gates below, or
// this render disagrees with `handle_welcome_input`'s `build_entry_map`
// (which receives the effective query) on row indices.
let filter_query =
crate::views::session_picker::effective_filter_query(&ctx.state.query, ctx.entries_query);
let filtered_indices =
crate::app::app_view::filter_session_entries(ctx.sessions, filter_query, ctx.source_filter);
let content_width = area.width; // approximate for truncation
let built = build_session_entry_data(entries_data, &filtered_indices, ctx.state, content_width);
// Build PickerEntry refs that borrow from `built`.
let fields_vecs: Vec<Vec<PickerField>> = built
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
// Build picker entries, optionally grouped by repo_name.
let (mut picker_entries, non_selectable_indices) = if ctx.grouped {
let current_repo =
crate::views::session_picker::repo_name_from_cwd(&ctx.cwd.to_string_lossy());
build_grouped_picker_entries(
entries_data,
&filtered_indices,
&built,
&fields_vecs,
ctx.state,
Some(current_repo.as_str()),
)
} else {
let entries: Vec<PickerEntry> = built
.iter()
.zip(fields_vecs.iter())
.map(|(b, fields)| {
PickerEntry::Row(PickerRow {
label: &b.summary,
right_label: &b.right_text,
selected: b.is_selected,
expanded: b.is_expanded,
fields,
description_lines: &[],
summary_lines: &[],
dimmed: false,
indent: 0,
badge: b.badge,
badge_color: None,
collapsible: b.collapsible,
})
})
.collect();
(entries, Vec::new())
};
// Append content search result rows (shared helper handles dedup).
use crate::views::session_picker::{build_content_entry_data, build_content_header_label};
// Content rows will start after fuzzy rows + 1 header row.
let content_start = picker_entries.len() + 1;
let content_entry_data: Vec<SessionEntryData> = if let Some(hits) = ctx.content_results
&& ctx.source_filter != crate::views::session_picker::SourceFilter::External
&& !filter_query.is_empty()
{
build_content_entry_data(
hits,
entries_data,
&filtered_indices,
ctx.state,
content_start,
)
} else {
Vec::new()
};
// Show header only if there are actual deduped content rows to display.
let has_content_rows = !content_entry_data.is_empty();
let content_loading = ctx.content_loading
&& ctx.source_filter != crate::views::session_picker::SourceFilter::External;
let spinner_label = build_content_header_label(content_loading, has_content_rows, ctx.tick);
// Only show the header when content results exist or when content
// search is in progress with a non-empty query. This must match the
// header condition inside `build_entry_map` as called from
// `handle_welcome_input` (app_view.rs) so the input handler's
// `entry_count` agrees with the rendered entry list — a mismatch causes
// arrow-key selection to target the wrong row. Both sides therefore gate
// on the same EFFECTIVE query (`filter_query`), not the live one.
let show_content_header =
has_content_rows || (content_loading && !filter_query.trim().is_empty());
if show_content_header {
picker_entries.push(PickerEntry::Header {
label: &spinner_label,
});
}
let content_fields: Vec<Vec<PickerField>> = content_entry_data
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
let content_snippets: Vec<[&str; 1]> = content_entry_data
.iter()
.map(|b| [b.snippet_preview.as_deref().unwrap_or("")])
.collect();
for (i, (b, fields)) in content_entry_data
.iter()
.zip(content_fields.iter())
.enumerate()
{
let has_snippet = b.snippet_preview.is_some();
picker_entries.push(PickerEntry::Row(PickerRow {
label: &b.summary,
right_label: &b.right_text,
selected: b.is_selected,
expanded: b.is_expanded,
fields,
description_lines: if has_snippet {
&content_snippets[i]
} else {
&[]
},
summary_lines: &[],
dimmed: false,
indent: 1,
badge: if has_snippet { "match" } else { "" },
badge_color: Some(theme.accent_user),
collapsible: true,
}));
}
// Build shortcuts for fullscreen mode. Chat mode drops the worktree /
// deep-search / filter hints (local-Build-row actions).
let worktree_shortcut: &'static str = "ctrl+w";
use crate::views::shortcuts_bar::HintItem;
let mut default_shortcuts: Vec<HintItem> = vec![
HintItem::new(crate::key!(Esc), "back"),
HintItem::new(crate::key!(Enter), "select"),
];
if !ctx.chat_mode {
default_shortcuts.push(HintItem {
keys: vec![],
label: "worktree".into(),
custom_display: Some(worktree_shortcut),
description: None,
pinned: false,
});
}
default_shortcuts.push(HintItem {
keys: vec![],
label: "navigate".into(),
custom_display: Some("\u{2191}\u{2193}"),
description: None,
pinned: false,
});
if !ctx.chat_mode {
default_shortcuts.push(HintItem {
keys: vec![],
label: "filter".into(),
custom_display: Some("f"),
description: None,
pinned: false,
});
}
let config = PickerConfig {
title: Some("Resume session"),
show_search_hint: true,
expandable: true,
esc_clears_query: true,
shortcuts: Some(&default_shortcuts),
pending_hint: ctx.pending_hint,
non_selectable: &non_selectable_indices,
non_selectable_clickable: &[],
shortcuts_area: ctx.shortcuts_area,
tabs: None,
active_tab: 0,
filter_label: (!ctx.chat_mode).then(|| ctx.source_filter.label()),
filter_key_hint: (!ctx.chat_mode).then_some("f"),
filter_active: !ctx.chat_mode && ctx.source_filter.is_active(),
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
picker::render_picker(
buf,
area,
theme,
ctx.state,
&picker_entries,
&config,
ctx.loading,
)
}
/// Render the auth token input box (loopback mode).
fn render_auth_input_box(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
input: &str,
placeholder: &str,
) {
let prompt_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent_user))
.padding(Padding {
left: 2,
right: 1,
top: 0,
bottom: 0,
});
let inner = prompt_block.inner(area);
prompt_block.render(area, buf);
if inner.height > 0 && inner.width > 2 {
let display = if input.is_empty() {
placeholder.to_string()
} else {
mask_auth_token_for_display(input)
};
let style = if input.is_empty() {
Style::default().fg(theme.gray_dim)
} else {
Style::default().fg(theme.accent_user)
};
let line = Line::from(vec![
Span::styled(
crate::glyphs::prompt_arrow(),
Style::default().fg(theme.accent_user),
),
Span::styled(display, style),
]);
buf.set_line(inner.x, inner.y, &line, inner.width);
}
}
/// Render the first startup warning centered in the given area.
///
/// `startup_warnings` can hold more than one entry (the WezTerm
/// kitty-keyboard banner is prepended ahead of `summarize_warnings()`
/// output — see `diagnostics::assemble_startup_warnings`), but only the
/// first is rendered; all of them point at `/terminal-setup`, which lists
/// every issue. One message line, one optional action line, plus a buffer
/// row for spacing. Severity controls color (yellow for `Warning`, dim
/// for `Info`).
fn render_startup_warnings(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
warnings: &[StartupWarning],
) -> Option<Rect> {
let w = warnings.first()?;
// Skip the import-claude startup warning entirely — the import row in the
// menu now carries the call-to-action with the same visual weight as
// every other welcome menu item. Showing the warning text in addition to
// the menu row would be redundant noise.
if w.message.starts_with("Import Claude settings")
|| w.message.starts_with("Claude settings detected")
{
return None;
}
let color = match w.severity {
crate::startup::WarningSeverity::Warning => theme.warning,
crate::startup::WarningSeverity::Info => theme.gray_dim,
};
let style = Style::default().fg(color);
let mut lines: Vec<Line<'_>> = w
.message
.lines()
.map(|l| Line::from(Span::styled(l, style)).alignment(Alignment::Center))
.collect();
if let Some(ref action) = w.action {
lines.push(Line::from(Span::styled(action.as_str(), style)).alignment(Alignment::Center));
}
Paragraph::new(lines).render(area, buf);
None
}
fn mask_auth_token_for_display(input: &str) -> String {
use crate::render::line_utils::floor_char_boundary;
if input.is_empty() {
return "Paste your token here...".to_string();
}
let len = input.len();
if len <= 8 {
return input.to_string();
}
let boundary = floor_char_boundary(input, len - 4);
let visible = &input[boundary..];
let masked_count = input[..boundary].chars().count();
format!("{}{}", "\u{2022}".repeat(masked_count), visible)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::app_view::SessionPickerEntry;
use crate::views::picker::PickerState;
use crate::views::session_picker::{build_grouped_picker_entries, build_session_entry_data};
#[test]
fn mask_auth_token_cases() {
assert_eq!(mask_auth_token_for_display(""), "Paste your token here...");
assert_eq!(mask_auth_token_for_display("12345678"), "12345678");
let masked = mask_auth_token_for_display("abcdefghij");
assert!(masked.ends_with("ghij"));
assert!(masked.starts_with("\u{2022}"));
// Regression: multi-byte input panicked on byte-index slicing
let masked = mask_auth_token_for_display("测试令牌一二三四五六");
assert!(masked.starts_with("\u{2022}"));
}
fn make_entry(id: &str, summary: &str, repo_name: &str) -> SessionPickerEntry {
SessionPickerEntry {
id: id.into(),
summary: summary.into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: format!("/home/user/{repo_name}"),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 1,
last_active_at: None,
branch: None,
repo_name: repo_name.into(),
worktree_label: None,
card_detail: None,
}
}
fn render_params<'a>(
auth_state: &'a AuthState,
trust_state: &'a TrustState,
session_picker: Option<&'a [SessionPickerEntry]>,
) -> WelcomeRenderParams<'a> {
WelcomeRenderParams {
prompt_focus: WelcomePromptFocus::Unfocused,
auth_state,
trust_state,
auth_methods: &[],
login_label: None,
auth_code_input: "",
clipboard_copied: false,
show_raw_url: false,
tip: None,
model_name: "test",
flags: &[],
selected: None,
has_claude_import: false,
mouse_pos: None,
session_picker,
session_picker_loading: false,
compact: false,
pending_hint: None,
startup_warnings: &[],
pending_update_version: None,
foreign_resume_hint: None,
is_api_key_auth: false,
session_picker_content_results: None,
session_picker_content_loading: false,
session_picker_entries_query: None,
welcome_tick: 0,
session_picker_grouped: false,
session_picker_source_filter: crate::views::session_picker::SourceFilter::All,
chat_mode: false,
cwd: std::path::Path::new("/repo"),
}
}
fn render_done_text(params: &WelcomeRenderParams<'_>) -> String {
render_done_text_h(params, 40)
}
/// Render at an explicit height. The login picker lists one row per
/// advertised platform; with ~20 API-key providers the menu no longer
/// fits a 40-row viewport, so the full-menu content test needs more rows
/// to verify every label renders. (On-terminal, rows past the fold are
/// clipped — the `q`/`l` shortcuts still work; see the menu-scroll debt.)
fn render_done_text_h(params: &WelcomeRenderParams<'_>, height: u16) -> String {
let area = Rect::new(0, 0, 100, height);
let mut buf = Buffer::empty(area);
let mut prompt = PromptWidget::new();
let mut picker = PickerState::default();
render_welcome(area, &mut buf, params, &mut prompt, &mut picker);
buffer_text(&buf)
}
/// The unauthenticated welcome menu lists one row per interactive login
/// method — the OAuth device login plus BOTH Moonshot open platforms —
/// and Quit, when the shell advertises all three.
#[test]
fn pending_menu_lists_three_login_rows_plus_quit() {
use kigi_shell::agent::auth_method::{AuthMethodsBuildInputs, build_auth_methods};
let built = build_auth_methods(AuthMethodsBuildInputs {
has_external_api_key: false,
has_cached_token: false,
login_label: None,
});
let auth = AuthState::Pending { error: None };
let trust = TrustState::Done;
let mut params = render_params(&auth, &trust, None);
params.auth_methods = &built.methods;
// Tall viewport so every advertised login row renders (the picker now
// lists ~20 platforms); this asserts content coverage, not fit.
let text = render_done_text_h(&params, 72);
assert!(text.contains("Kimi Code (OAuth)"), "{text}");
assert!(
text.contains("xAI Grok (subscription) (OAuth)"),
"the xai-grok interactive OAuth login row must render: {text}"
);
assert!(
text.contains("Claude Pro/Max (subscription) (OAuth)"),
"the claude-pro-max interactive OAuth login row must render: {text}"
);
assert!(
text.contains("GitHub Copilot (subscription) (OAuth)"),
"the github-copilot interactive OAuth login row must render: {text}"
);
assert!(
text.contains("Moonshot Open Platform (API key \u{b7} moonshot.cn)"),
"{text}"
);
assert!(
text.contains("Moonshot Open Platform (API key \u{b7} moonshot.ai)"),
"{text}"
);
assert!(text.contains("Quit"), "{text}");
// Shortcut hints for muscle memory: `l` (first row) and `q` (Quit).
assert!(text.contains('l'), "{text}");
assert!(text.contains('q'), "{text}");
}
/// An old/limited shell that advertises only `kimi-code` keeps the
/// two-row shape (single login row + Quit) — and never a Moonshot row.
#[test]
fn pending_menu_without_moonshot_methods_keeps_two_rows() {
let methods = vec![kigi_shell::agent::auth_method::kimi_code_auth_method(None)];
let auth = AuthState::Pending { error: None };
let trust = TrustState::Done;
let mut params = render_params(&auth, &trust, None);
params.auth_methods = &methods;
let text = render_done_text(&params);
assert!(text.contains("Kimi Code (OAuth)"), "{text}");
assert!(!text.contains("Moonshot"), "{text}");
}
/// The Moonshot API-key entry arm renders the platform copy, the paste
/// box, and the esc-back hint — and no OAuth-URL affordances.
#[test]
fn api_key_entry_arm_shows_platform_copy_and_paste_box() {
let area = Rect::new(0, 0, 80, 40);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let (copy_rect, fallback_rect) = render_welcome_authenticating(
area,
&mut buf,
&theme,
logo_line_count(area.height),
None, // auth_url — none in key-entry mode
AuthMode::ApiKeyEntry(crate::app::app_view::PlatformLogin(
kigi_shell::models::PlatformId::MoonshotCn,
)),
"", // auth_code_input
false, // clipboard_copied
false, // show_raw_url
);
let text = buffer_text(&buf);
// The instruction may soft-wrap; assert its two halves (each stays an
// intact word run on one row).
assert!(
text.contains("Paste your Moonshot API key"),
"key-entry arm must show the platform instruction, got:\n{text}"
);
assert!(
text.contains("platform.moonshot.cn"),
"key-entry arm must name the platform console, got:\n{text}"
);
assert!(
text.contains("Paste your API key here..."),
"key-entry arm must render the paste box placeholder, got:\n{text}"
);
assert!(
text.contains("esc") && text.contains("back"),
"key-entry arm must hint esc-back, got:\n{text}"
);
assert!(copy_rect.is_none() && fallback_rect.is_none());
}
#[test]
fn foreign_resume_tip_names_each_tool_and_age() {
use kigi_workspace::foreign_sessions::ForeignSessionTool;
let auth = AuthState::Done;
let trust = TrustState::Done;
for (tool, label) in [
(ForeignSessionTool::Claude, "Claude Code"),
(ForeignSessionTool::Codex, "Codex"),
(ForeignSessionTool::Cursor, "Cursor"),
] {
let hint = kigi_workspace::foreign_sessions::RecentForeignSession {
tool,
native_id: "native-id".into(),
age: std::time::Duration::from_secs(125),
};
let mut params = render_params(&auth, &trust, None);
params.foreign_resume_hint = Some(&hint);
let text = render_done_text(&params);
assert!(text.contains(&format!("Coming from {label}?")), "{text}");
assert!(text.contains("2m ago"), "{text}");
assert!(text.contains("ctrl+u"), "{text}");
}
}
#[test]
fn pending_update_suppresses_foreign_resume_tip() {
let auth = AuthState::Done;
let trust = TrustState::Done;
let hint = kigi_workspace::foreign_sessions::RecentForeignSession {
tool: kigi_workspace::foreign_sessions::ForeignSessionTool::Cursor,
native_id: "native-id".into(),
age: std::time::Duration::from_secs(30),
};
let mut params = render_params(&auth, &trust, None);
params.foreign_resume_hint = Some(&hint);
params.pending_update_version = Some("9.9.9");
let text = render_done_text(&params);
assert!(text.contains("v9.9.9 available"), "{text}");
assert!(!text.contains("Coming from Cursor?"), "{text}");
}
fn png() -> [u8; 8] {
[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
fn seed_static_owner(owner_id: u64) {
let _ = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, owner_id)
.unwrap()
.commit();
}
fn assert_promptless_clear(result: WelcomeRenderResult, owner_id: u64) {
let post_flush = result
.post_flush_escapes
.expect("promptless welcome must clear ID 1");
assert!(post_flush.as_str().contains("a=d"));
let before_write =
crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, owner_id).unwrap();
assert!(
!before_write.as_str().contains("a=t"),
"constructing the clear must not commit ownership"
);
post_flush.write_to(&mut Vec::new()).unwrap();
let after_write =
crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, owner_id).unwrap();
assert!(
after_write.as_str().contains("a=t"),
"writing the clear must commit ownership"
);
}
#[test]
fn authenticating_welcome_returns_paired_overlay_clear() {
let _guard = crate::terminal::image::set_protocol_for_test(
crate::terminal::image::GraphicsProtocol::Kitty,
);
crate::terminal::overlay::reset_owner();
seed_static_owner(81);
let auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Command,
};
let trust_state = TrustState::Done;
let params = render_params(&auth_state, &trust_state, None);
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
let mut prompt = PromptWidget::new();
let mut picker = PickerState::default();
let result = render_welcome(area, &mut buf, &params, &mut prompt, &mut picker);
assert_promptless_clear(result, 81);
}
#[test]
fn picker_welcome_returns_paired_overlay_clear() {
let _guard = crate::terminal::image::set_protocol_for_test(
crate::terminal::image::GraphicsProtocol::Kitty,
);
crate::terminal::overlay::reset_owner();
seed_static_owner(82);
let auth_state = AuthState::Done;
let trust_state = TrustState::Done;
let sessions = [make_entry("session-1", "summary", "repo")];
let params = render_params(&auth_state, &trust_state, Some(&sessions));
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
let mut prompt = PromptWidget::new();
let mut picker = PickerState::default();
let result = render_welcome(area, &mut buf, &params, &mut prompt, &mut picker);
assert_promptless_clear(result, 82);
}
/// RENDER half of the header-gate invariant (input half:
/// `session_picker::tests::grouped_entry_map_empty_query_with_loading_has_no_header`):
/// with stamp==live and a re-search in flight, the "Searching…" header
/// must NOT render — a render-only header row shifts arrow-key row
/// indices. Control leg: the same search WITHOUT the stamp keeps it.
#[test]
fn render_header_gate_uses_effective_query() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let theme = crate::theme::Theme::default();
let area = Rect::new(0, 0, 80, 20);
// Content-only hit: title shares nothing with the query "hit".
let entries = vec![make_entry("conv-1", "Quarterly roadmap notes", "repo")];
let render = |entries_query: Option<&str>| -> String {
let mut buf = Buffer::empty(area);
let mut state = PickerState {
query: "hit".into(),
..PickerState::default()
};
render_session_picker(
area,
&mut buf,
&theme,
&mut SessionPickerRenderCtx {
state: &mut state,
sessions: Some(&entries),
cwd: std::path::Path::new("/repo"),
loading: false,
pending_hint: None,
shortcuts_area: None,
content_results: None,
content_loading: true,
entries_query,
tick: 0,
grouped: false,
source_filter: crate::views::session_picker::SourceFilter::All,
chat_mode: true,
},
);
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let stamped = render(Some("hit"));
assert!(
!stamped.contains("Searching session content"),
"stamp==live must not render the search header:\n{stamped}"
);
assert!(
stamped.contains("Quarterly roadmap notes"),
"stamped server hit must render:\n{stamped}"
);
// Control: unstamped in-flight search keeps the header, proving the
// negative assertion above exercises the gate.
let unstamped = render(None);
assert!(
unstamped.contains("Searching session content"),
"in-flight search without the stamp must render the header:\n{unstamped}"
);
}
#[test]
fn grouped_entries_insert_headers() {
let entries = vec![
make_entry("s1", "Fix auth", "xai"),
make_entry("s2", "Add streaming", "xai"),
make_entry("s3", "Nuke tables", "fw-1"),
];
let indices: Vec<usize> = (0..entries.len()).collect();
let state = PickerState::default();
let built = build_session_entry_data(&entries, &indices, &state, 80);
let fields_vecs: Vec<Vec<crate::views::picker::PickerField>> =
built.iter().map(|_| Vec::new()).collect();
let (result, non_sel) =
build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None);
// 2 headers + 3 rows = 5 entries
assert_eq!(result.len(), 5);
// Groups are sorted alphabetically: fw-1 before xai.
// Header positions: 0 (fw-1), 2 (xai)
assert_eq!(non_sel.len(), 5);
assert!(non_sel[0], "first entry should be header (non-selectable)");
assert!(!non_sel[1], "second entry should be selectable row");
assert!(non_sel[2], "third entry should be header (non-selectable)");
assert!(!non_sel[3], "fourth entry should be selectable row");
assert!(!non_sel[4], "fifth entry should be selectable row");
// Verify headers
assert!(
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"fw-1")
);
assert!(
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"xai")
);
}
#[test]
fn grouped_entries_pin_current_repo_first() {
// Render path (build_grouped_picker_entries) must pin the current
// working directory's repo group ahead of the alphabetical rest,
// matching build_entry_map's index ordering.
let entries = vec![
make_entry("s1", "Fix auth", "aaa"),
make_entry("s2", "Add streaming", "zzz"),
];
let indices: Vec<usize> = (0..entries.len()).collect();
let state = PickerState::default();
let built = build_session_entry_data(&entries, &indices, &state, 80);
let fields_vecs: Vec<Vec<crate::views::picker::PickerField>> =
built.iter().map(|_| Vec::new()).collect();
// Pin "zzz": it leads despite sorting last alphabetically.
let (result, _) = build_grouped_picker_entries(
&entries,
&indices,
&built,
&fields_vecs,
&state,
Some("zzz"),
);
assert!(
matches!(&result[0], crate::views::picker::PickerEntry::Header { label } if label == &"zzz"),
"current repo group pinned first"
);
assert!(
matches!(&result[2], crate::views::picker::PickerEntry::Header { label } if label == &"aaa"),
"remaining group follows alphabetically"
);
}
#[test]
fn grouped_entries_single_group_has_one_header() {
let entries = vec![
make_entry("s1", "Fix auth", "xai"),
make_entry("s2", "Add streaming", "xai"),
];
let indices: Vec<usize> = (0..entries.len()).collect();
let state = PickerState::default();
let built = build_session_entry_data(&entries, &indices, &state, 80);
let fields_vecs: Vec<Vec<crate::views::picker::PickerField>> =
built.iter().map(|_| Vec::new()).collect();
let (result, non_sel) =
build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None);
assert_eq!(result.len(), 3); // 1 header + 2 rows
assert!(non_sel[0]);
assert!(!non_sel[1]);
assert!(!non_sel[2]);
}
#[test]
fn grouped_entries_empty_input() {
let entries: Vec<SessionPickerEntry> = vec![];
let indices: Vec<usize> = vec![];
let state = PickerState::default();
let built = build_session_entry_data(&entries, &indices, &state, 80);
let fields_vecs: Vec<Vec<crate::views::picker::PickerField>> = vec![];
let (result, non_sel) =
build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None);
assert!(result.is_empty());
assert!(non_sel.is_empty());
}
#[test]
fn grouped_entries_rows_are_indented() {
let entries = vec![make_entry("s1", "Fix auth", "xai")];
let indices: Vec<usize> = vec![0];
let state = PickerState::default();
let built = build_session_entry_data(&entries, &indices, &state, 80);
let fields_vecs: Vec<Vec<crate::views::picker::PickerField>> =
built.iter().map(|_| Vec::new()).collect();
let (result, _) =
build_grouped_picker_entries(&entries, &indices, &built, &fields_vecs, &state, None);
// The row (second entry) should have indent=1
if let crate::views::picker::PickerEntry::Row(row) = &result[1] {
assert_eq!(row.indent, 1);
} else {
panic!("expected Row, got Header");
}
}
fn resume_picker_config() -> crate::views::picker::PickerConfig<'static> {
crate::views::picker::PickerConfig {
title: Some("Resume session"),
show_search_hint: true,
expandable: true,
esc_clears_query: true,
shortcuts: None,
pending_hint: None,
non_selectable: &[],
non_selectable_clickable: &[],
shortcuts_area: None,
tabs: None,
active_tab: 0,
filter_label: None,
filter_key_hint: None,
filter_active: false,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: false,
}
}
#[test]
fn e_key_expands_selected_entry_in_resume_picker() {
use crate::views::picker::{PickerOutcome, handle_picker_input};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let mut state = PickerState::default();
let config = resume_picker_config();
let ev = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
let outcome = handle_picker_input(&ev, &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Expand(0)));
}
#[test]
fn e_key_routes_to_search_when_active() {
use crate::views::picker::{PickerOutcome, handle_picker_input};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let mut state = PickerState {
search_active: true,
..PickerState::default()
};
let config = resume_picker_config();
let ev = Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
let outcome = handle_picker_input(&ev, &mut state, 3, &config);
assert!(matches!(outcome, PickerOutcome::Changed));
assert_eq!(state.query, "e");
}
#[test]
fn hero_box_active_on_wide_tall_terminal() {
// 90 cols, 50 rows: meets the minimum for the hero box.
let area = Rect::new(0, 0, 90, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
..Default::default()
});
assert!(layout.has_hero_box(), "hero box should be active at 90x50");
assert!(layout.hero_box.width > 0);
assert!(layout.hero_box.height > 0);
// Logo and menu slots are zero in hero box mode (content is inside the box).
assert_eq!(layout.logo.width, 0);
assert_eq!(layout.menu.width, 0);
// Sub-rects inside the hero box are valid.
assert!(layout.hero_logo.height > 0);
assert!(layout.hero_menu.height > 0);
assert_eq!(layout.hero_version.height, 1);
assert_eq!(layout.hero_subtitle.height, 1);
}
#[test]
fn hero_box_inactive_on_narrow_terminal() {
// 80 cols is below the 90-col threshold.
let area = Rect::new(0, 0, 80, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
..Default::default()
});
assert!(
!layout.has_hero_box(),
"hero box should be inactive at 80x50"
);
assert_eq!(layout.hero_box.width, 0);
}
#[test]
fn hero_box_boundary_at_min_width() {
let just_below = Rect::new(0, 0, 89, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: just_below,
menu_height: 4,
..Default::default()
});
assert!(
!layout.has_hero_box(),
"hero box should be inactive at 89 cols"
);
let at_threshold = Rect::new(0, 0, 90, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: at_threshold,
menu_height: 4,
..Default::default()
});
assert!(
layout.has_hero_box(),
"hero box should be active at 90 cols"
);
}
#[test]
fn hero_box_inactive_when_compact() {
// Compact mode (session picker visible) never uses the hero box.
let area = Rect::new(0, 0, 120, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
compact: true,
prompt_compact: true,
..Default::default()
});
assert!(
!layout.has_hero_box(),
"hero box should be inactive in compact mode"
);
assert_eq!(layout.hero_box.width, 0);
}
#[test]
fn hero_box_inactive_on_short_terminal() {
// 16 rows is one short of the 17 the box needs (11 box + 1 flex gap +
// 5 fixed-below), so it falls back to the stacked layout.
let area = Rect::new(0, 0, 90, 16);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
..Default::default()
});
assert!(
!layout.has_hero_box(),
"hero box should be inactive at 90x16 (needs 17 rows)"
);
}
#[test]
fn hero_box_inactive_when_warning_would_overflow() {
// Regression: the box is forced to the full 10-row moon, so even a
// 3-item menu needs 14 box rows (min content 20). A startup warning
// (error_height = 2, +1 gap) pushes the total past height 20, so the
// gate must fall back to the stacked layout instead of overflowing.
let area = Rect::new(0, 0, 90, 20);
let with_warning = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
error_height: 2,
menu_height: 3,
..Default::default()
});
assert!(!with_warning.has_hero_box());
// The same terminal fits the box once the warning is gone.
let no_warning = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 3,
..Default::default()
});
assert!(no_warning.has_hero_box());
}
#[test]
fn blocked_layout_stays_stacked_on_wide_terminal() {
// The login / ZDR screens render through render_welcome_blocked, which
// only paints the stacked logo/menu rects. compute_stacked must never
// hand them a hero-box layout (which zeroes those rects), even on a
// wide, tall terminal where the normal path picks the hero box.
let area = Rect::new(0, 0, 120, 40);
assert!(
WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 2,
..Default::default()
})
.has_hero_box(),
"sanity: the normal path should pick the hero box at 120x40"
);
let blocked = WelcomeLayout::compute_stacked(WelcomeLayoutInput {
content_area: area,
menu_height: 2,
..Default::default()
});
assert!(!blocked.has_hero_box());
assert!(
blocked.logo.height > 0,
"logo must be painted on the login screen"
);
assert!(
blocked.menu.height > 0,
"menu must be painted on the login screen"
);
}
#[test]
fn hero_box_does_not_overflow_with_tall_menu() {
// A tall menu can outgrow the default box, so the centering pad
// (derived from the default box) must be clamped or the box gets
// pushed down and the version row clips at exactly
// min_content_height. 20 == min_content_height(0, 6, 0, 0): a 14-row
// box (10-row moon) + 1 flex gap + 5 fixed-below.
let area = Rect::new(0, 0, 100, 20);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 6,
..Default::default()
});
assert!(
layout.has_hero_box(),
"hero box should be active at the boundary"
);
// top_pad must clamp to 0, so the box sits at the top, not pushed down.
assert_eq!(
layout.hero_box.y, area.y,
"box pushed down by unclamped pad"
);
assert!(
layout.version.y + layout.version.height <= area.y + area.height,
"version row (y={}, h={}) overflows content height {}",
layout.version.y,
layout.version.height,
area.height,
);
}
#[test]
fn hero_box_height_accounts_for_borders_and_padding() {
// At h >= 26 the full moon is used (10 lines). With menu_height=3:
// right_col = 2 + 0 + 0 + 1 + 3 = 6, inner = max(10, 6) = 10.
// hero_box_height = 2 (borders) + 2 (v_pad) + 10 = 14.
let area = Rect::new(0, 0, 100, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 3,
..Default::default()
});
assert!(layout.has_hero_box());
assert_eq!(layout.hero_box.height, 14);
}
#[test]
fn hero_box_logo_top_aligned() {
let area = Rect::new(0, 0, 100, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 3,
..Default::default()
});
// Logo y should be at hero_box.y + 1 (border) + 1 (v_pad).
assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2);
}
/// Flatten a rendered buffer into one string for substring assertions.
fn buffer_text(buf: &Buffer) -> String {
let area = *buf.area();
let mut out = String::new();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn extract_user_code_parses_verification_url() {
// The live Kimi device flow returns this exact URL shape.
assert_eq!(
extract_user_code("https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH"),
Some("ABCD-EFGH"),
);
// Trailing params after the code are ignored.
assert_eq!(
extract_user_code(
"https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234&foo=bar"
),
Some("WXYZ-1234"),
);
// A param whose name merely ends in `user_code` must not be matched.
assert_eq!(
extract_user_code("https://example.com/d?foo_user_code=BAD&user_code=GOOD"),
Some("GOOD"),
);
// No code param, empty code, and unexpected characters all yield None.
assert_eq!(
extract_user_code("https://www.kimi.com/code/authorize_device"),
None
);
assert_eq!(extract_user_code("https://example.com/d?user_code="), None);
assert_eq!(
extract_user_code("https://example.com/d?user_code=AB%20CD"),
None
);
}
#[test]
fn device_auth_arm_shows_url_and_no_paste_box() {
let area = Rect::new(0, 0, 80, 40);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let url = "https://www.kimi.com/code/authorize_device?user_code=ABCD-EFGH";
let (copy_rect, fallback_rect) = render_welcome_authenticating(
area,
&mut buf,
&theme,
logo_line_count(area.height),
Some(url),
AuthMode::Device,
"", // auth_code_input — unused in device mode
false, // clipboard_copied
false, // show_raw_url
);
let text = buffer_text(&buf);
assert!(
text.contains("Approve in your browser"),
"device arm must show the approval header, got:\n{text}"
);
// Device code shown for the browser-match check (anti-phishing).
assert!(
text.contains("ABCD-EFGH"),
"device arm must show the device code, got:\n{text}"
);
assert!(
text.contains("Make sure your browser shows this code"),
"device arm must show the code caption, got:\n{text}"
);
// Copy affordance (click-to-copy line) is present.
assert!(
text.contains("to copy"),
"device arm must show the copy-URL affordance, got:\n{text}"
);
// No manual-paste affordance in device mode.
assert!(
!text.contains("Paste your token"),
"device arm must NOT render the token paste box, got:\n{text}"
);
// Copy + fallback links are clickable.
assert!(
copy_rect.is_some(),
"device arm must expose a copy hit-rect"
);
assert!(
fallback_rect.is_some(),
"device arm must expose a show-full-URL hit-rect"
);
}
#[test]
fn device_auth_arm_raw_url_mode_shows_full_url() {
let area = Rect::new(0, 0, 80, 40);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let url = "https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234";
render_welcome_authenticating(
area,
&mut buf,
&theme,
logo_line_count(area.height),
Some(url),
AuthMode::Device,
"",
false,
true, // show_raw_url
);
let text = buffer_text(&buf);
assert!(
text.contains("WXYZ-1234"),
"raw URL mode must render the full URL including the user code, got:\n{text}"
);
}
#[test]
fn raw_url_mode_centers_url_that_fits_on_one_line() {
let area = Rect::new(0, 0, 80, 40);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let url = "https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234";
render_welcome_authenticating(
area,
&mut buf,
&theme,
logo_line_count(area.height),
Some(url),
AuthMode::Device,
"",
false,
true, // show_raw_url
);
let text = buffer_text(&buf);
let url_line = text
.lines()
.find(|l| l.contains("https://"))
.expect("raw URL mode must render the URL");
// Whole URL on one line, not wrapped.
assert!(url_line.contains(url), "URL must be intact: {url_line:?}");
// Centered: leading pad within 1 cell of trailing pad (integer split).
let lead = url_line.len() - url_line.trim_start().len();
let trail = url_line.len() - url_line.trim_end().len();
assert!(
lead > 0 && lead.abs_diff(trail) <= 1,
"URL must be horizontally centered, lead={lead} trail={trail}:\n{text}"
);
}
#[test]
fn raw_url_mode_uses_full_width_for_long_urls() {
let area = Rect::new(0, 0, 40, 40);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
// 40-col terminal; URL longer than one row must wrap at the exact
// screen edge with no leading spaces so copy-paste stays intact.
let url = "https://www.kimi.com/code/authorize_device?user_code=WXYZ-1234&extra=0123456789";
render_welcome_authenticating(
area,
&mut buf,
&theme,
logo_line_count(area.height),
Some(url),
AuthMode::Device,
"",
false,
true, // show_raw_url
);
let text = buffer_text(&buf);
let mut lines = text.lines();
let first = lines
.by_ref()
.find(|l| l.contains("https://"))
.expect("raw URL mode must render the URL");
let second = lines.next().expect("URL must wrap to a second row");
// First row flush against both edges (full width), remainder on the
// next row starting at column 0.
assert_eq!(
first,
&url[..40],
"long URL row must span the full terminal width:\n{text}"
);
assert!(
second.starts_with(&url[40..]),
"wrapped remainder must start at column 0:\n{text}"
);
}
#[test]
fn command_auth_arm_shows_url_and_waiting() {
let area = Rect::new(0, 0, 80, 40);
let mut buf = Buffer::empty(area);
let theme = Theme::current();
let url = "https://example.com/oauth2/authorize?client_id=kigi";
let (copy_rect, fallback_rect) = render_welcome_authenticating(
area,
&mut buf,
&theme,
logo_line_count(area.height),
Some(url),
AuthMode::Command,
"", // auth_code_input — unused
false, // clipboard_copied
false, // show_raw_url
);
let text = buffer_text(&buf);
assert!(
text.contains("A browser window will open"),
"command arm must show the auth header, got:\n{text}"
);
assert!(
text.contains("Waiting for login to complete"),
"command arm must show the waiting status, got:\n{text}"
);
// No device code — that's device-flow only.
assert!(
!text.contains("Make sure your browser shows this code"),
"command arm must NOT show the device-code caption, got:\n{text}"
);
// No manual-paste affordance in command mode.
assert!(
!text.contains("Paste your token"),
"command arm must NOT render the token paste box, got:\n{text}"
);
// Copy + fallback links are clickable.
assert!(
copy_rect.is_some(),
"command arm must expose a copy hit-rect"
);
assert!(
fallback_rect.is_some(),
"command arm must expose a show-full-URL hit-rect"
);
}
}