Skip to content
Open
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
4 changes: 2 additions & 2 deletions docs/CODEX_OAUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ TermAI supports three authentication methods for OpenAI:

The Codex OAuth flow uses the same authentication mechanism as OpenAI's official Codex CLI, enabling users to leverage their existing ChatGPT subscription.

The Codex provider is transport-based, not tied to a specific model-name suffix. It defaults to `gpt-5.4`, and legacy `*-codex` model ids remain accepted for backward compatibility.
The Codex provider is transport-based, not tied to a specific model-name suffix. It defaults to `gpt-5.5`, and legacy `gpt-5.4` plus `*-codex` model ids remain accepted for backward compatibility.

## OAuth Configuration

Expand Down Expand Up @@ -170,7 +170,7 @@ The setup wizard (`termai setup`) includes Codex as a provider option:
? Which AI provider would you like to use?
Claude (Anthropic) - Best for analysis & coding
OpenAI (API Key) - Versatile general purpose
> OpenAI Codex (ChatGPT Plus/Pro) - OAuth provider, defaults to gpt-5.4
> OpenAI Codex (ChatGPT Plus/Pro) - OAuth provider, defaults to gpt-5.5
Both providers (recommended)
```

Expand Down
14 changes: 0 additions & 14 deletions src/args/structs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,6 @@ pub struct ChatArgs {
pub chunk_strategy: String,
}

impl ChatArgs {
/// Apply environment variable fallbacks to chat arguments
pub fn with_env_fallbacks(self) -> Self {
self
}
}

/// Arguments for one-shot ask questions
#[derive(Args, Debug, Clone)]
pub struct AskArgs {
Expand Down Expand Up @@ -129,13 +122,6 @@ pub struct AskArgs {
pub chunk_strategy: String,
}

impl AskArgs {
/// Apply environment variable fallbacks to ask arguments
pub fn with_env_fallbacks(self) -> Self {
self
}
}

/// Arguments for session management operations
#[derive(Args, Debug, Clone)]
pub struct SessionArgs {
Expand Down
141 changes: 124 additions & 17 deletions src/chat/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ pub enum ChatCommand {

// Session management
Save(Option<String>),
NewSession(Option<String>),
ListSessions,
LoadSession(String),
RenameSession(String),
Clear,
Retry,
Branch(Option<String>),
Expand Down Expand Up @@ -111,6 +115,24 @@ impl ChatCommand {
};
Some(ChatCommand::Save(name))
}
"new" | "n" => {
let name = if parts.len() > 1 {
Some(parts[1..].join(" "))
} else {
None
};
Some(ChatCommand::NewSession(name))
}
"sessions" | "ls" | "list" => Some(ChatCommand::ListSessions),
"load" | "open" | "switch" => {
// Missing arg returns an empty name; the handler shows usage.
Some(ChatCommand::LoadSession(
parts.get(1..).map(|p| p.join(" ")).unwrap_or_default(),
))
}
"rename" => Some(ChatCommand::RenameSession(
parts.get(1..).map(|p| p.join(" ")).unwrap_or_default(),
)),
"clear" | "c" => Some(ChatCommand::Clear),
"retry" | "r" => Some(ChatCommand::Retry),
"branch" | "b" => {
Expand All @@ -124,20 +146,12 @@ impl ChatCommand {

// Context
"context" | "ctx" => Some(ChatCommand::Context),
"add" => {
if parts.len() > 1 {
Some(ChatCommand::AddContext(parts[1..].join(" ")))
} else {
None
}
}
"remove" | "rm" => {
if parts.len() > 1 {
Some(ChatCommand::RemoveContext(parts[1..].join(" ")))
} else {
None
}
}
"add" => Some(ChatCommand::AddContext(
parts.get(1..).map(|p| p.join(" ")).unwrap_or_default(),
)),
"remove" | "rm" => Some(ChatCommand::RemoveContext(
parts.get(1..).map(|p| p.join(" ")).unwrap_or_default(),
)),

// AI settings
"model" | "m" => {
Expand Down Expand Up @@ -202,6 +216,10 @@ impl ChatCommand {
ChatCommand::Commands => "Open command palette with all commands",
ChatCommand::Exit | ChatCommand::Quit => "Exit chat mode",
ChatCommand::Save(_) => "Save current session with optional name",
ChatCommand::NewSession(_) => "Start a new session (saves current first)",
ChatCommand::ListSessions => "List saved sessions",
ChatCommand::LoadSession(_) => "Load/switch to a saved session",
ChatCommand::RenameSession(_) => "Rename the active session",
ChatCommand::Clear => "Clear conversation history",
ChatCommand::Retry => "Regenerate the last AI response",
ChatCommand::Branch(_) => "Create a new conversation branch",
Expand Down Expand Up @@ -244,7 +262,31 @@ impl ChatCommand {
CommandEntry {
command: "/save [name]",
aliases: "/s",
description: "Save session with optional name",
description: "Save / name the current conversation",
category: CommandCategory::Session,
},
CommandEntry {
command: "/sessions",
aliases: "/ls, /list",
description: "List saved sessions",
category: CommandCategory::Session,
},
CommandEntry {
command: "/new [name]",
aliases: "/n",
description: "Start a fresh session (auto-saves current)",
category: CommandCategory::Session,
},
CommandEntry {
command: "/load <name>",
aliases: "/switch, /open",
description: "Switch to a saved session's history",
category: CommandCategory::Session,
},
CommandEntry {
command: "/rename <name>",
aliases: "",
description: "Rename the active session",
category: CommandCategory::Session,
},
CommandEntry {
Expand Down Expand Up @@ -354,13 +396,21 @@ impl ChatCommand {
pub enum InputType {
Command(ChatCommand),
Message(String),
/// Looks like a slash command (starts with `/`) but matched no known verb.
UnknownCommand(String),
}

impl InputType {
/// Classify user input as either a command or a regular message
/// Classify user input as a command, an unknown command, or a regular message.
pub fn classify(input: &str) -> Self {
if let Some(command) = ChatCommand::parse(input) {
let trimmed = input.trim();
if let Some(command) = ChatCommand::parse(trimmed) {
InputType::Command(command)
} else if trimmed.starts_with('/') {
// Started with '/' but no verb matched β€” a typo'd command. Surface a
// hint instead of silently billing it to the model as a chat message.
let verb = trimmed.split_whitespace().next().unwrap_or(trimmed);
InputType::UnknownCommand(verb.to_string())
} else {
InputType::Message(input.to_string())
}
Expand Down Expand Up @@ -440,6 +490,63 @@ mod tests {
);
}

#[test]
fn test_session_management_commands() {
assert_eq!(
ChatCommand::parse("/new"),
Some(ChatCommand::NewSession(None))
);
assert_eq!(
ChatCommand::parse("/new my-plan"),
Some(ChatCommand::NewSession(Some("my-plan".to_string())))
);
assert_eq!(
ChatCommand::parse("/n"),
Some(ChatCommand::NewSession(None))
);
assert_eq!(
ChatCommand::parse("/sessions"),
Some(ChatCommand::ListSessions)
);
assert_eq!(ChatCommand::parse("/ls"), Some(ChatCommand::ListSessions));
assert_eq!(ChatCommand::parse("/list"), Some(ChatCommand::ListSessions));
assert_eq!(
ChatCommand::parse("/load refactor"),
Some(ChatCommand::LoadSession("refactor".to_string()))
);
assert_eq!(
ChatCommand::parse("/switch refactor"),
Some(ChatCommand::LoadSession("refactor".to_string()))
);
// Missing arg parses to an empty name (handler shows usage), NOT unknown.
assert_eq!(
ChatCommand::parse("/load"),
Some(ChatCommand::LoadSession(String::new()))
);
assert_eq!(
ChatCommand::parse("/rename new-name"),
Some(ChatCommand::RenameSession("new-name".to_string()))
);
}

#[test]
fn test_unknown_command_classification() {
match InputType::classify("/saev oops") {
InputType::UnknownCommand(verb) => assert_eq!(verb, "/saev"),
other => panic!("expected UnknownCommand, got {:?}", other),
}
// A known verb with a missing arg is NOT unknown.
match InputType::classify("/load") {
InputType::Command(ChatCommand::LoadSession(_)) => {}
other => panic!("expected LoadSession command, got {:?}", other),
}
// A normal message is unaffected.
match InputType::classify("what is 2 + 2?") {
InputType::Message(_) => {}
other => panic!("expected Message, got {:?}", other),
}
}

#[test]
fn test_input_classification() {
match InputType::classify("/help") {
Expand Down
86 changes: 85 additions & 1 deletion src/chat/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,11 +521,15 @@ impl ChatFormatter {
}

/// Enable or disable streaming
#[allow(dead_code)]
pub fn set_streaming(&mut self, enabled: bool) {
self.enable_streaming = enabled;
}

/// Whether streaming output is currently enabled (single source of truth).
pub fn is_streaming(&self) -> bool {
self.enable_streaming
}

/// Enable or disable markdown formatting
#[allow(dead_code)]
pub fn set_markdown(&mut self, enabled: bool) {
Expand Down Expand Up @@ -829,6 +833,86 @@ impl ChatFormatter {
.to_string()
}

/// One-line banner shown under the welcome panel and after session switches,
/// so the user always knows which session/model they're talking to.
pub fn format_session_banner(
&self,
session_label: &str,
message_count: usize,
provider: &str,
model: &str,
) -> String {
format!(
" {} {} {} {} {} {}",
"session".dimmed(),
session_label.bright_cyan(),
"model".dimmed(),
format!("{}/{}", provider, model).bright_white(),
"messages".dimmed(),
message_count.to_string().bright_white(),
)
}

/// Render the saved-session list (from `/sessions`), marking the active one.
pub fn format_session_list(
&self,
sessions: &[crate::session::model::session::Session],
current_id: &str,
current_is_temporary: bool,
) -> String {
let mut out = String::new();
out.push_str(&"πŸ“š Saved Sessions".bright_blue().bold().to_string());
out.push('\n');

if sessions.is_empty() && !current_is_temporary {
out.push_str(
&" No saved sessions yet β€” use /save <name> to keep this one."
.dimmed()
.to_string(),
);
return out;
}

if current_is_temporary {
out.push_str(&format!(
" {} {} {}\n",
"β€Ί".bright_green().bold(),
"temporary (unsaved)".bright_yellow(),
"← current".dimmed()
));
}

for s in sessions {
let is_current = s.id == current_id;
let marker = if is_current {
"β€Ί".bright_green().bold().to_string()
} else {
" ".to_string()
};
let current_tag = if is_current {
format!(" {}", "← current".dimmed())
} else {
String::new()
};
// Pad the plain name before coloring so ANSI codes don't skew width.
let name_col = format!("{:<28}", s.name);
out.push_str(&format!(
" {} {} {}{}\n",
marker,
name_col.bright_cyan(),
format!("{} msg", s.messages.len()).dimmed(),
current_tag
));
}

out.push_str(
&" /load <name> to switch β€’ /new for a fresh session β€’ /rename <name>"
.dimmed()
.to_string(),
);
out
}

/// Format conversation cleared message
pub fn format_conversation_cleared(&self) -> String {
"πŸ—‘οΈ Conversation history cleared"
Expand Down
Loading
Loading