Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
607 changes: 574 additions & 33 deletions src/commands.rs

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ pub fn command_help(command: &str) -> Option<String> {
),
"menu" => fill(
&bin,
"{bin} menu — open the centered TUI launcher (default on a TTY)\n\nUsage:\n {bin} Same as `{bin} menu` on a TTY\n {bin} menu [--dump-tui]\n\nA compact dialog: brand + status (account / model / agent / credits),\nthen actions — launch an agent, open Config, sign in, or quit.\n\nKeys: ↑↓ / j k move · ↵ select · q / esc quit\n\nConfig opens the existing settings flow (model, account, key, credits).\n`--dump-tui` / ANYR_TUI_DUMP=1 prints one plain frame (for tests and pipes).\n",
"{bin} menu — open the centered TUI launcher (default on a TTY)\n\nUsage:\n {bin} Same as `{bin} menu` on a TTY\n {bin} menu [--dump-tui]\n\nA compact dialog: brand + status (account / model / agent / credits),\nthen actions — launch an agent, open Config, sign in, or quit.\n\nKeys: ↑↓ / j k move · ↵ select · q / esc quit\n\nConfig opens the grouped settings screen (account, keys, model slots,\nagent, auto-update) with current values on every row.\n`--dump-tui` / ANYR_TUI_DUMP=1 prints one plain frame (for tests and pipes).\n",
),
"prompt" => fill(
&bin,
Expand Down Expand Up @@ -345,17 +345,20 @@ Options:
";

const CONFIG: &str = "\
Interactive config: pick key, account, model, and see credits.
Interactive config: accounts, keys, models, agent, credits, updates.

USAGE
{bin} config Open the TUI (TTY)
{bin} config Open the settings TUI (TTY)
{bin} config get [--json] Print current status
{bin} config path Print the config file path
{bin} config use <account> Switch the active account

On a TTY, `{bin} config` opens a centered dialog until you pick Done: switch
key, account, model (default / haiku / sonnet / opus / fable), view credits, sign
in, or log out. Also reachable from the launcher via Config.
On a TTY, `{bin} config` opens a grouped settings screen — Account, Model,
Agent, General — each row showing its current value. ↑↓ / j k navigate,
↵ edits the focused row (switch / add / re-auth / log out accounts, pick a
key or model slot, choose the coding agent, toggle auto-update, switch
channel), x resets a row to its default, q / esc closes.
Also reachable from the launcher via Config.
`--dump-tui` prints one plain frame and exits.
";

Expand Down
80 changes: 80 additions & 0 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,61 @@ pub fn fetch_credits(base_url: &str, api_key: &str) -> Result<serde_json::Value,
serde_json::from_str(&body).map_err(|e| format!("Invalid credits response: {e}"))
}

/// Identity from GET `/v1/me` (accepts `sk-ar-` inference keys).
#[derive(Debug, Clone, Default)]
pub struct MeInfo {
pub email: Option<String>,
pub name: Option<String>,
pub username: Option<String>,
pub balance: Option<f64>,
}

impl MeInfo {
/// `username · email` style label, falling back through name/email/username.
pub fn display_label(&self) -> String {
let handle = self
.username
.as_deref()
.filter(|s| !s.is_empty())
.or(self.name.as_deref().filter(|s| !s.is_empty()))
.or(self.email.as_deref().filter(|s| !s.is_empty()));
match (handle, self.email.as_deref()) {
(Some(h), Some(e)) if !e.eq_ignore_ascii_case(h) => format!("{h} · {e}"),
(Some(h), _) => h.to_string(),
(None, _) => "—".into(),
}
}
}

pub fn parse_me(body: &str) -> Result<MeInfo, String> {
let value: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Invalid /me response: {e}"))?;
Ok(MeInfo {
email: value
.get("email")
.and_then(|v| v.as_str())
.map(str::to_string),
name: value
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string),
username: value
.get("username")
.and_then(|v| v.as_str())
.map(str::to_string),
balance: value.get("balance").and_then(|v| v.as_f64()),
})
}

pub fn fetch_me(base_url: &str, api_key: &str) -> Result<MeInfo, String> {
let url = join_api(base_url, "/v1/me");
let (status, body) = http_get(&url, Some(api_key))?;
if !(200..300).contains(&status) {
return Err(format!("Could not fetch account info (HTTP {status})."));
}
parse_me(&body)
}

pub fn format_usd(n: f64) -> String {
if !n.is_finite() {
return "$?".into();
Expand Down Expand Up @@ -421,6 +476,31 @@ mod tests {
assert!(keys[0].can_reveal);
}

#[test]
fn parse_me_reads_identity_and_balance() {
let me = parse_me(
r#"{"id":"user_1","email":"a@b.co","name":"duyet","image_url":"https://x/y.png","username":"duyet","balance":129.22}"#,
)
.unwrap();
assert_eq!(me.email.as_deref(), Some("a@b.co"));
assert_eq!(me.username.as_deref(), Some("duyet"));
assert_eq!(me.balance, Some(129.22));
assert_eq!(me.display_label(), "duyet · a@b.co");
}

#[test]
fn me_display_label_falls_back_gracefully() {
let mut me = MeInfo::default();
assert_eq!(me.display_label(), "—");
me.email = Some("solo@b.co".into());
assert_eq!(me.display_label(), "solo@b.co");
me.name = Some("Solo".into());
assert_eq!(me.display_label(), "Solo · solo@b.co");
// Email used as handle should not repeat.
me.username = Some("solo@b.co".into());
assert_eq!(me.display_label(), "solo@b.co");
}

#[test]
fn is_active_key_row_matches_prefix_only() {
assert!(is_active_key_row(
Expand Down
30 changes: 28 additions & 2 deletions src/tui/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
pub enum Surface {
Launcher,
Picker,
Settings,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -12,6 +13,8 @@ pub enum Action {
Enter,
Up,
Down,
/// Reset the focused settings row to its default (`x`).
Unset,
Backspace,
Esc,
Char(char),
Expand Down Expand Up @@ -54,8 +57,10 @@ pub fn map_key(surface: Surface, key: KeyEvent) -> Action {
KeyCode::Backspace | KeyCode::Delete => Action::Backspace,
KeyCode::Char(c) => match (surface, c) {
(Surface::Launcher, 'q' | 'x' | 'Q' | 'X') => Action::Quit,
(Surface::Launcher, 'j') => Action::Down,
(Surface::Launcher, 'k') => Action::Up,
(Surface::Settings, 'q') => Action::Quit,
(Surface::Settings, 'x' | 'X') => Action::Unset,
(Surface::Launcher | Surface::Settings, 'j') => Action::Down,
(Surface::Launcher | Surface::Settings, 'k') => Action::Up,
(_, c) => Action::Char(c),
},
}
Expand All @@ -64,6 +69,7 @@ pub fn map_key(surface: Surface, key: KeyEvent) -> Action {
pub fn hint_line(surface: Surface) -> &'static str {
match surface {
Surface::Launcher => "↑↓/jk move ↵ select q/esc quit",
Surface::Settings => "↑↓/jk move ↵ edit x reset q/esc close",
Surface::Picker => "type to search ↑↓ move ↵ select esc cancel",
}
}
Expand Down Expand Up @@ -107,4 +113,24 @@ mod tests {
);
assert_eq!(a, Action::Quit);
}

#[test]
fn settings_x_resets_and_q_quits() {
let x = map_key(
Surface::Settings,
KeyEvent {
code: KeyCode::Char('x'),
ctrl: false,
},
);
assert_eq!(x, Action::Unset);
let q = map_key(
Surface::Settings,
KeyEvent {
code: KeyCode::Char('q'),
ctrl: false,
},
);
assert_eq!(q, Action::Quit);
}
}
38 changes: 36 additions & 2 deletions src/tui/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;

use super::keys::{map_key, KeyCode, KeyEvent, Surface};
use super::state::{MenuState, Outcome, PickerState};
use super::view::{plain_menu_frame, plain_picker_frame, render_menu, render_picker};
use super::state::{MenuState, Outcome, PickerState, SettingsOutcome, SettingsState};
use super::view::{
plain_menu_frame, plain_picker_frame, plain_settings_frame, render_menu, render_picker,
render_settings,
};

pub fn is_interactive() -> bool {
use std::io::IsTerminal;
Expand Down Expand Up @@ -125,3 +128,34 @@ pub fn dump_picker(state: &PickerState, cols: usize) -> String {
pub fn dump_menu(state: &MenuState, cols: usize) -> String {
plain_menu_frame(state, cols)
}

pub fn run_settings_live(mut state: SettingsState) -> Result<SettingsOutcome, String> {
let mut live = LiveTerminal::start()?;
loop {
live.terminal
.draw(|f| render_settings(f, &state))
.map_err(|e| e.to_string())?;
if !event::poll(Duration::from_millis(200)).map_err(|e| e.to_string())? {
continue;
}
match event::read().map_err(|e| e.to_string())? {
Event::Resize(_, _) => {
state.apply(super::keys::Action::Resize);
}
Event::Key(ev) => {
let Some(key) = translate_key(ev) else {
continue;
};
let outcome = state.apply(map_key(Surface::Settings, key));
if outcome != SettingsOutcome::Stay {
return Ok(outcome);
}
}
_ => {}
}
}
}

pub fn dump_settings(state: &SettingsState, cols: usize) -> String {
plain_settings_frame(state, cols)
}
17 changes: 14 additions & 3 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ use std::collections::BTreeMap;
use crate::parse::ParsedArgs;

pub use keys::Action;
pub use live::{dump_menu, dump_picker, is_interactive, run_menu_live, run_picker_live};
pub use state::{drive_menu, drive_picker, MenuState, Outcome, PickerState};
pub use live::{
dump_menu, dump_picker, dump_settings, is_interactive, run_menu_live, run_picker_live,
run_settings_live,
};
pub use state::{
drive_menu, drive_picker, MenuState, Outcome, PickerState, SettingRow, SettingsOutcome,
SettingsState, Tone,
};

pub fn wants_dump(parsed: &ParsedArgs, env: &BTreeMap<String, String>) -> bool {
parsed.flag_true("dump-tui")
Expand Down Expand Up @@ -83,7 +89,12 @@ pub fn run_menu_select(
}

/// Dump one launcher frame (for `--dump-tui` / `ANYR_TUI_DUMP`).
pub fn dump_menu_select(title: &str, header: Vec<String>, items: Vec<String>, cols: usize) -> String {
pub fn dump_menu_select(
title: &str,
header: Vec<String>,
items: Vec<String>,
cols: usize,
) -> String {
dump_menu(&MenuState::new(title, header, items), cols)
}

Expand Down
Loading
Loading