mod init;
mod input;
mod render;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::io::{self, Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use dialoguer::Select;
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use api::{
TernlangClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, OutputContentBlock,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::{render_slash_command_help, slash_command_specs, SlashCommand};
use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo;
use render::TerminalRenderer;
use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
PermissionMode, PermissionPolicy,
ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
};
use serde_json::json;
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
const DEFAULT_MODEL: &str = "gemini-2.5-pro-preview";
fn max_tokens_for_model(model: &str) -> u32 {
if model.contains("haiku") {
16_000
} else {
32_000
}
}
const DEFAULT_DATE: &str = "2024-03-25";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
type AllowedToolSet = BTreeSet<String>;
fn main() {
if let Err(error) = run() {
eprintln!(
"error: {error}
Run `claw --help` for usage."
);
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? {
CliAction::DumpManifests => dump_manifests(),
CliAction::BootstrapPlan => print_bootstrap_plan(),
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
CliAction::Version => print_version(),
CliAction::ResumeSession {
session_path,
commands,
} => resume_session(&session_path, &commands),
CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
} => LiveCli::new(auto_select_model(&model), true, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format),
CliAction::Login => run_login(),
CliAction::Logout => run_logout(),
CliAction::Init => run_init(),
CliAction::Repl {
model,
allowed_tools,
permission_mode,
} => {
let mut config_path = dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"));
config_path.push("albert/config.toml");
if !config_path.exists() {
init::wake_sequence();
}
run_repl(auto_select_model(&model), allowed_tools, permission_mode)
},
CliAction::Help => {
println!("{}", render_repl_help());
Ok(())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
PrintSystemPrompt {
cwd: PathBuf,
date: String,
},
Version,
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
},
Prompt {
prompt: String,
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
Login,
Logout,
Init,
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CliOutputFormat {
Text,
Json,
}
impl CliOutputFormat {
fn parse(value: &str) -> Result<Self, String> {
match value {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
other => Err(format!(
"unsupported value for --output-format: {other} (expected text or json)"
)),
}
}
}
#[allow(clippy::too_many_lines)]
fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string();
let mut output_format = CliOutputFormat::Text;
let mut permission_mode = default_permission_mode();
let mut wants_version = false;
let mut allowed_tool_values = Vec::new();
let mut rest = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--version" | "-V" => {
wants_version = true;
index += 1;
}
"--model" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
model = resolve_model_alias(value).to_string();
index += 2;
}
flag if flag.starts_with("--model=") => {
model = resolve_model_alias(&flag[8..]).to_string();
index += 1;
}
"--output-format" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --output-format".to_string())?;
output_format = CliOutputFormat::parse(value)?;
index += 2;
}
"--permission-mode" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --permission-mode".to_string())?;
permission_mode = parse_permission_mode_arg(value)?;
index += 2;
}
flag if flag.starts_with("--output-format=") => {
output_format = CliOutputFormat::parse(&flag[16..])?;
index += 1;
}
flag if flag.starts_with("--permission-mode=") => {
permission_mode = parse_permission_mode_arg(&flag[18..])?;
index += 1;
}
"--dangerously-skip-permissions" => {
permission_mode = PermissionMode::DangerFullAccess;
index += 1;
}
"-p" => {
let prompt = args[index + 1..].join(" ");
if prompt.trim().is_empty() {
return Err("-p requires a prompt string".to_string());
}
return Ok(CliAction::Prompt {
prompt,
model: resolve_model_alias(&model).to_string(),
output_format,
allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
permission_mode,
});
}
"--print" => {
output_format = CliOutputFormat::Text;
index += 1;
}
"--allowedTools" | "--allowed-tools" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --allowedTools".to_string())?;
allowed_tool_values.push(value.clone());
index += 2;
}
flag if flag.starts_with("--allowedTools=") => {
allowed_tool_values.push(flag[15..].to_string());
index += 1;
}
flag if flag.starts_with("--allowed-tools=") => {
allowed_tool_values.push(flag[16..].to_string());
index += 1;
}
other => {
rest.push(other.to_string());
index += 1;
}
}
}
if wants_version {
return Ok(CliAction::Version);
}
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
if rest.is_empty() {
return Ok(CliAction::Repl {
model,
allowed_tools,
permission_mode,
});
}
if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) {
return Ok(CliAction::Help);
}
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..]);
}
match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout),
"init" => Ok(CliAction::Init),
"prompt" => {
let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() {
return Err("prompt subcommand requires a prompt string".to_string());
}
Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
})
}
other if !other.starts_with('/') => Ok(CliAction::Prompt {
prompt: rest.join(" "),
model,
output_format,
allowed_tools,
permission_mode,
}),
other => Err(format!("unknown subcommand: {other}")),
}
}
fn resolve_model_alias(model: &str) -> &str {
match model {
"opus" => "claude-3-opus-20240229",
"sonnet" => "claude-3-sonnet-20240229",
"haiku" => "claude-3-haiku-20240307",
"flash" => "gemini-2.0-flash",
"pro" => "gemini-1.5-pro",
_ => model,
}
}
fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
if values.is_empty() {
return Ok(None);
}
let canonical_names = mvp_tool_specs()
.into_iter()
.map(|spec| spec.name.to_string())
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
for (alias, canonical) in [
("read", "read_file"),
("write", "write_file"),
("edit", "edit_file"),
("glob", "glob_search"),
("grep", "grep_search"),
] {
name_map.insert(alias.to_string(), canonical.to_string());
}
let mut allowed = AllowedToolSet::new();
for value in values {
for token in value
.split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty())
{
let normalized = normalize_tool_name(token);
let canonical = name_map.get(&normalized).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
)
})?;
allowed.insert(canonical.clone());
}
}
Ok(Some(allowed))
}
fn normalize_tool_name(value: &str) -> String {
value.trim().replace('-', "_").to_ascii_lowercase()
}
fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
normalize_permission_mode(value)
.ok_or_else(|| {
format!(
"unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
)
})
.map(permission_mode_from_label)
}
fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode {
"read-only" => PermissionMode::ReadOnly,
"workspace-write" => PermissionMode::WorkspaceWrite,
"danger-full-access" => PermissionMode::DangerFullAccess,
other => panic!("unsupported permission mode label: {other}"),
}
}
fn default_permission_mode() -> PermissionMode {
env::var("RUSTY_TERNLANG_PERMISSION_MODE")
.ok()
.as_deref()
.and_then(normalize_permission_mode)
.map_or(PermissionMode::DangerFullAccess, permission_mode_from_label)
}
fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec<tools::ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.collect()
}
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--cwd" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --cwd".to_string())?;
cwd = PathBuf::from(value);
index += 2;
}
"--date" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --date".to_string())?;
date.clone_from(value);
index += 2;
}
other => return Err(format!("unknown system-prompt option: {other}")),
}
}
Ok(CliAction::PrintSystemPrompt { cwd, date })
}
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
let session_path = args
.first()
.ok_or_else(|| "missing session path for --resume".to_string())
.map(PathBuf::from)?;
let commands = args[1..].to_vec();
if commands
.iter()
.any(|command| !command.trim_start().starts_with('/'))
{
return Err("--resume trailing arguments must be slash commands".to_string());
}
Ok(CliAction::ResumeSession {
session_path,
commands,
})
}
fn dump_manifests() -> Result<(), Box<dyn std::error::Error>> {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
let manifest = extract_manifest(&paths)?;
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
Ok(())
}
fn print_bootstrap_plan() -> Result<(), Box<dyn std::error::Error>> {
for phase in runtime::BootstrapPlan::ternlang_cli_default().phases() {
println!("- {phase:?}");
}
Ok(())
}
fn default_oauth_config() -> OAuthConfig {
OAuthConfig {
client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"),
authorize_url: String::from("https://console.anthropic.com/oauth/authorize"),
token_url: String::from("https://api.anthropic.com/v1/oauth/token"),
callback_port: None,
manual_redirect_url: None,
scopes: vec![
String::from("user:profile"),
String::from("user:inference"),
String::from("user:sessions:ternlang_cli"),
],
}
}
fn run_login() -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?;
let default_oauth = default_oauth_config();
let oauth = config.oauth().unwrap_or(&default_oauth);
let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
let redirect_uri = runtime::loopback_redirect_uri(callback_port);
let pkce = generate_pkce_pair()?;
let state = generate_state()?;
let authorize_url =
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
.build_url();
println!("Starting Anthropic OAuth login...");
println!("Listening for callback on {redirect_uri}");
if let Err(error) = open_browser(&authorize_url) {
eprintln!("warning: failed to open browser automatically: {error}");
println!("Open this URL manually:
{authorize_url}");
}
let callback = wait_for_oauth_callback(callback_port)?;
if let Some(error) = callback.error {
let description = callback
.error_description
.unwrap_or_else(|| "authorization failed".to_string());
return Err(io::Error::other(format!("{error}: {description}")).into());
}
let code = callback.code.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
})?;
let returned_state = callback.state.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
})?;
if returned_state != state {
return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
}
let client = TernlangClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
let exchange_request = api::OAuthTokenExchangeRequest {
code,
redirect_uri,
};
let runtime = tokio::runtime::Runtime::new()?;
let token_set = runtime.block_on(client.exchange_oauth_code(api::OAuthConfig {}, &exchange_request))?;
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: token_set.access_token,
refresh_token: token_set.refresh_token,
expires_at: token_set.expires_at,
scopes: token_set.scopes,
})?;
println!("Anthropic OAuth login complete.");
Ok(())
}
fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
clear_oauth_credentials()?;
println!("Anthropic OAuth credentials cleared.");
Ok(())
}
fn open_browser(url: &str) -> io::Result<()> {
let commands = if cfg!(target_os = "macos") {
vec![("open", vec![url])]
} else if cfg!(target_os = "windows") {
vec![("cmd", vec!["/C", "start", "", url])]
} else {
vec![("xdg-open", vec![url])]
};
for (program, args) in commands {
match Command::new(program).args(args).spawn() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"no supported browser opener command found",
))
}
fn wait_for_oauth_callback(
port: u16,
) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let (mut stream, _) = listener.accept()?;
let mut buffer = [0_u8; 4096];
let bytes_read = stream.read(&mut buffer)?;
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
let request_line = request.lines().next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
})?;
let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"missing callback request target",
)
})?;
let callback = parse_oauth_callback_request_target(target)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let body = if callback.error.is_some() {
"Anthropic OAuth login failed. You can close this window."
} else {
"Anthropic OAuth login succeeded. You can close this window."
};
let response = format!(
"HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: {}
connection: close
{}",
body.len(),
body
);
stream.write_all(response.as_bytes())?;
Ok(callback)
}
fn print_system_prompt(cwd: PathBuf, date: String) -> Result<(), Box<dyn std::error::Error>> {
let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?;
println!("{}", sections.join("
"));
Ok(())
}
fn print_version() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_version_report());
Ok(())
}
fn resume_session(session_path: &Path, commands: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let mut session = Session::load_from_path(session_path)?;
if commands.is_empty() {
println!(
"Restored session from {} ({} messages).",
session_path.display(),
session.messages.len()
);
return Ok(());
}
for raw_command in commands {
let Some(command) = SlashCommand::parse(raw_command) else {
eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2);
};
let outcome = run_resume_command(session_path, &session, &command)?;
session = outcome.session;
if let Some(message) = outcome.message {
println!("{message}");
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct ResumeCommandOutcome {
session: Session,
message: Option<String>,
}
#[derive(Debug, Clone)]
struct StatusContext {
cwd: PathBuf,
session_path: Option<PathBuf>,
loaded_config_files: usize,
discovered_config_files: usize,
memory_file_count: usize,
project_root: Option<PathBuf>,
git_branch: Option<String>,
}
#[derive(Debug, Clone, Copy)]
struct StatusUsage {
message_count: usize,
turns: u32,
latest: TokenUsage,
cumulative: TokenUsage,
estimated_tokens: usize,
}
fn format_model_report(model: &str, message_count: usize, turns: u32) -> String {
format!(
"Model
Current model {model}
Session messages {message_count}
Session turns {turns}
Usage
Inspect current model with /model
Switch models with /model <name>"
)
}
fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String {
format!(
"Model updated
Previous {previous}
Current {next}
Preserved msgs {message_count}"
)
}
fn format_permissions_report(mode: &str) -> String {
let modes = [
("read-only", "Read/search tools only", mode == "read-only"),
(
"workspace-write",
"Edit files inside the workspace",
mode == "workspace-write",
),
(
"danger-full-access",
"Unrestricted tool access",
mode == "danger-full-access",
),
]
.into_iter()
.map(|(name, description, is_current)| {
let marker = if is_current {
"● current"
} else {
"○ available"
};
format!(" {name:<18} {marker:<11} {description}")
})
.collect::<Vec<_>>()
.join(
"
",
);
format!(
"Permissions
Active mode {mode}
Mode status live session default
Modes
{modes}
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
)
}
fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!(
"Permissions updated
Result mode switched
Previous mode {previous}
Active mode {next}
Applies to subsequent tool calls
Usage /permissions to inspect current mode"
)
}
fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
)
}
fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
Session file {session_path}
Messages {message_count}
Turns {turns}"
)
}
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped {
format!(
"Compact
Result skipped
Reason session below compaction threshold
Messages kept {resulting_messages}"
)
} else {
format!(
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}"
)
}
}
fn format_auto_compaction_notice(removed: usize) -> String {
format!("[auto-compacted: removed {removed} messages]")
}
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(status) = status else {
return (None, None);
};
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
})
.filter(|value| !value.is_empty())
});
let project_root = find_git_root().ok();
(project_root, branch)
}
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err("empty git root".into());
}
Ok(PathBuf::from(path))
}
#[allow(clippy::too_many_lines)]
fn run_resume_command(
session_path: &Path,
session: &Session,
command: &SlashCommand,
) -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
match command {
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
}),
SlashCommand::Compact => {
let result = runtime::compact_session(
session,
CompactionConfig {
max_estimated_tokens: 0,
..CompactionConfig::default()
},
);
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
result.compacted_session.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: result.compacted_session,
message: Some(format_compact_report(removed, kept, skipped)),
})
}
SlashCommand::Clear { confirm } => {
if !confirm {
return Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(
"clear: confirmation required; rerun with /clear --confirm".to_string(),
),
});
}
let cleared = Session::new();
cleared.save_to_path(session_path)?;
Ok(ResumeCommandOutcome {
session: cleared,
message: Some(format!(
"Cleared resumed session file {}.",
session_path.display()
)),
})
}
SlashCommand::Status => {
let tracker = UsageTracker::from_session(session);
let usage = tracker.cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_status_report(
"restored-session",
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
latest: tracker.current_turn_usage(),
cumulative: usage,
estimated_tokens: 0,
},
default_permission_mode().as_str(),
&status_context(Some(session_path))?,
)),
})
}
SlashCommand::Cost => {
let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_cost_report(usage)),
})
}
SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_config_report(section.as_deref())?),
}),
SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_memory_report()?),
}),
SlashCommand::Init => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(init_ternlang_md()?),
}),
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report()?),
}),
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_version_report()),
}),
SlashCommand::Export { path } => {
let export_path = resolve_export_path(path.as_deref(), session)?;
fs::write(&export_path, render_export_text(session))?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format!(
"Export
Result wrote transcript
File {}
Messages {}",
export_path.display(),
session.messages.len(),
)),
})
}
SlashCommand::Auth { .. }
| SlashCommand::Bughunter { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
| SlashCommand::Ultraplan { .. }
| SlashCommand::Teleport { .. }
| SlashCommand::DebugToolCall
| SlashCommand::Resume { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plan { .. }
| SlashCommand::Tdd { .. }
| SlashCommand::Verify
| SlashCommand::CodeReview { .. }
| SlashCommand::BuildFix
| SlashCommand::Aside { .. }
| SlashCommand::Learn
| SlashCommand::Refactor { .. }
| SlashCommand::Checkpoint { .. }
| SlashCommand::Docs { .. }
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Compress
| SlashCommand::Loop { .. }
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
}
}
fn run_repl(
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<(), Box<dyn std::error::Error>> {
check_workspace_trust()?;
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
let mut editor = input::LineEditor::new("> ", slash_command_completion_candidates());
println!("{}", cli.startup_banner());
loop {
print_status_bar(&cli.model, &cli.session.id, cli.runtime.session().messages.len());
match editor.read_line()? {
input::ReadOutcome::Submit(input) => {
let raw = input.trim().to_string();
if raw.is_empty() {
continue;
}
if matches!(raw.as_str(), "/exit" | "/quit") {
cli.persist_session()?;
break;
}
let trimmed = if raw == "/" || (raw.starts_with('/') && SlashCommand::parse(&raw).is_none()) {
match show_slash_picker(&raw) {
Some(chosen) => chosen,
None => continue,
}
} else {
raw
};
if let Some(command) = SlashCommand::parse(&trimmed) {
if cli.handle_repl_command(command)? {
cli.persist_session()?;
}
continue;
}
editor.push_history(input);
let (text_without_images, image_paths) = extract_image_attachments(&trimmed);
let final_input = if image_paths.is_empty() {
trimmed
} else {
let mut parts = Vec::new();
for path in &image_paths {
match load_image_as_base64(path) {
Ok((mime, b64)) => {
parts.push(format!("[ATTACHED IMAGE — mime:{mime}]\ndata:base64:{b64}"));
println!("{} {}", style("📎").cyan(), path);
}
Err(e) => eprintln!("{} {}", style("✘").red(), e),
}
}
parts.push(text_without_images.clone());
let combined = parts.join("\n\n");
cli.run_turn(&combined)?;
continue;
};
cli.run_turn(&final_input)?;
}
input::ReadOutcome::Cancel => {}
input::ReadOutcome::Exit => {
cli.persist_session()?;
break;
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct SessionHandle {
id: String,
path: PathBuf,
}
#[derive(Debug, Clone)]
struct ManagedSessionSummary {
id: String,
path: PathBuf,
modified_epoch_secs: u64,
message_count: usize,
}
struct LiveCli {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
system_prompt: Vec<String>,
runtime: ConversationRuntime<TernlangRuntimeClient, CliToolExecutor>,
session: SessionHandle,
stop_flag: Arc<AtomicBool>,
}
impl LiveCli {
fn new(
model: String,
enable_tools: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?;
let runtime = build_runtime(
Session::new(),
model.clone(),
system_prompt.clone(),
enable_tools,
true,
allowed_tools.clone(),
permission_mode,
)?;
let cli = Self {
model,
allowed_tools,
permission_mode,
system_prompt,
runtime,
session,
stop_flag: Arc::new(AtomicBool::new(false)),
};
cli.persist_session()?;
Ok(cli)
}
fn startup_banner(&self) -> String {
let cwd = env::current_dir().map_or_else(
|_| "<unknown>".to_string(),
|path| path.display().to_string(),
);
format!(
"
█████╗ ██╗ ██████╗ ███████╗██████╗ ████████╗
██╔══██╗ ██║ ██╔══██╗██╔════╝██╔══██╗╚══██╔══╝
███████║ ██║ ██████╔╝█████╗ ██████╔╝ ██║
██╔══██║ ██║ ██╔══██╗██╔══╝ ██╔══██╗ ██║
██║ ██║ ███████╗██████╔╝███████╗██║ ██║ ██║
╚═╝ ╚═╝ ╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
Model {}
Permissions {}
Directory {}
Session {}
Type /help for commands · Shift+Enter for newline",
self.model,
self.permission_mode.as_str(),
cwd,
self.session.id,
)
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan.bold} {msg}")
.unwrap()
.tick_strings(&["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏","⠿"]),
);
pb.set_message("thinking...");
pb.enable_steady_tick(Duration::from_millis(80));
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode, true);
let result = self
.runtime
.run_turn(input.to_string(), Some(&mut permission_prompter));
match result {
Ok(summary) => {
pb.finish_and_clear();
let response_text = final_assistant_text(&summary);
if !response_text.is_empty() {
typewriter_print(&TerminalRenderer::new().render_markdown(&response_text));
}
if let Some(event) = summary.auto_compaction {
println!(
"{}",
format_auto_compaction_notice(event.removed_message_count)
);
}
self.persist_session()?;
let score = score_turn_importance(input, &response_text);
if score >= 0.6 && !response_text.is_empty() {
let summary_text = distill_memory_summary(input, &response_text);
if let Err(e) = append_to_albert_memory(&summary_text) {
eprintln!("{} memory write failed: {e}", style("⚠").yellow());
} else {
println!("{}", style(" Noted.").dim().italic());
}
}
Ok(())
}
Err(error) => {
pb.finish_and_clear();
let error_str = error.to_string();
if error_str.contains("429") || error_str.contains("RESOURCE_EXHAUSTED") {
let retry_secs = parse_retry_after(&error_str).unwrap_or(30);
let fallback = fallback_model_for(&self.model);
eprintln!("{} Rate limited on {}. {}",
style("⚠").yellow().bold(),
self.model,
if retry_secs > 0 { format!("Retry in {retry_secs}s.") } else { String::new() }
);
if let Some(fb) = fallback {
eprintln!("{} Switching to {}...", style("→").cyan(), fb);
let _ = self.set_model(Some(fb.to_string()));
return self.run_turn(input);
}
}
let clean = clean_error_message(&error_str);
eprintln!("{} {}", style("✘").red().bold(), clean);
Err(Box::new(error))
}
}
}
fn run_turn_with_output(
&mut self,
input: &str,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => self.run_turn(input),
CliOutputFormat::Json => self.run_prompt_json(input),
}
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let session = self.runtime.session().clone();
let mut runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
false,
self.allowed_tools.clone(),
self.permission_mode,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode, false);
let summary = runtime.run_turn(input.to_string(), Some(&mut permission_prompter))?;
self.runtime = runtime;
self.persist_session()?;
println!(
"{}",
json!({
"message": final_assistant_text(&summary),
"model": self.model,
"iterations": summary.iterations,
"auto_compaction": summary.auto_compaction.map(|event| json!({
"removed_messages": event.removed_message_count,
"notice": format_auto_compaction_notice(event.removed_message_count),
})),
"tool_uses": collect_tool_uses(&summary),
"tool_results": collect_tool_results(&summary),
"usage": {
"input_tokens": summary.usage.input_tokens,
"output_tokens": summary.usage.output_tokens,
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
}
})
);
Ok(())
}
fn handle_repl_command(
&mut self,
command: SlashCommand,
) -> Result<bool, Box<dyn std::error::Error>> {
Ok(match command {
SlashCommand::Help => {
println!("{}", render_repl_help());
false
}
SlashCommand::Status => {
self.print_status()?;
false
}
SlashCommand::Bughunter { scope } => {
self.run_bughunter(scope.as_deref())?;
false
}
SlashCommand::Commit => {
self.run_commit()?;
true
}
SlashCommand::Pr { context } => {
self.run_pr(context.as_deref())?;
false
}
SlashCommand::Issue { context } => {
self.run_issue(context.as_deref())?;
false
}
SlashCommand::Ultraplan { task } => {
self.run_ultraplan(task.as_deref())?;
false
}
SlashCommand::Teleport { target } => {
self.run_teleport(target.as_deref())?;
false
}
SlashCommand::DebugToolCall => {
self.run_debug_tool_call()?;
false
}
SlashCommand::Compact => {
self.compact()?;
false
}
SlashCommand::Compress => {
self.compress()?;
false
}
SlashCommand::Model { model } => self.set_model(model)?,
SlashCommand::Permissions { mode } => self.set_permissions(mode)?,
SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
SlashCommand::Cost => {
self.print_cost();
false
}
SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
SlashCommand::Config { section } => {
Self::print_config(section.as_deref())?;
false
}
SlashCommand::Memory => {
Self::print_memory()?;
false
}
SlashCommand::Init => {
run_init()?;
false
}
SlashCommand::Diff => {
Self::print_diff()?;
false
}
SlashCommand::Version => {
Self::print_version()?;
false
}
SlashCommand::Export { path } => {
self.export_session(path.as_deref())?;
false
}
SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Auth { provider } => {
self.run_auth(provider.as_deref())?;
false
}
SlashCommand::Plan { task } => {
self.run_plan(task.as_deref())?;
true
}
SlashCommand::Tdd { interface } => {
println!("TDD loop engaged for {}. Scaffold -> Failing Test -> Implement.", interface.unwrap_or_else(|| "target".to_string()));
false
}
SlashCommand::Verify => {
println!("Running full verification: build, lint, test, and type-check...");
false
}
SlashCommand::CodeReview { files } => {
println!("Reviewing {}. Assessing quality, security, and maintainability.", files.unwrap_or_else(|| "changed files".to_string()));
false
}
SlashCommand::BuildFix => {
println!("Detecting build errors. Dispatching resolver agents...");
false
}
SlashCommand::Aside { question } => {
println!("Pivot: {}. Answering side question without losing context.", question.unwrap_or_else(|| "...".to_string()));
false
}
SlashCommand::Learn => {
println!("Extracting reusable patterns and learned instincts...");
false
}
SlashCommand::Refactor { scope } => {
println!("Refactoring {}. Removing dead code and consolidating duplicates.", scope.unwrap_or_else(|| "workspace".to_string()));
false
}
SlashCommand::Checkpoint { label } => {
println!("Checkpoint marked: {}.", label.unwrap_or_else(|| "manual".to_string()));
false
}
SlashCommand::Docs { query } => {
println!("Looking up docs for {}. Querying Context7...", query.unwrap_or_else(|| "project".to_string()));
false
}
SlashCommand::Loop { mission } => {
self.run_loop(mission.as_deref())?;
false
}
SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}");
false
}
})
}
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
self.runtime.session().save_to_path(&self.session.path)?;
Ok(())
}
fn print_status(&self) -> Result<(), Box<dyn std::error::Error>> {
let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage();
println!(
"{}",
format_status_report(
&self.model,
StatusUsage {
message_count: self.runtime.session().messages.len(),
turns: self.runtime.usage().turns(),
latest,
cumulative,
estimated_tokens: self.runtime.estimated_tokens(),
},
self.permission_mode.as_str(),
&status_context(Some(&self.session.path))?,
)
);
Ok(())
}
fn set_model(&mut self, model: Option<String>) -> Result<bool, Box<dyn std::error::Error>> {
let Some(model) = model else {
match show_model_picker(&self.model) {
Some(chosen) => return self.set_model(Some(chosen)),
None => {
println!(
"{}",
format_model_report(
&self.model,
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
)
);
return Ok(false);
}
}
};
let model = resolve_model_alias(&model).to_string();
if model == self.model {
println!(
"{}",
format_model_report(
&self.model,
self.runtime.session().messages.len(),
self.runtime.usage().turns(),
)
);
return Ok(false);
}
let previous = self.model.clone();
let session = self.runtime.session().clone();
let message_count = session.messages.len();
self.runtime = build_runtime(
session,
model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.model.clone_from(&model);
println!(
"{}",
format_model_switch_report(&previous, &model, message_count)
);
Ok(true)
}
fn set_permissions(
&mut self,
mode: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(mode) = mode else {
println!(
"{}",
format_permissions_report(self.permission_mode.as_str())
);
return Ok(false);
};
let normalized = normalize_permission_mode(&mode).ok_or_else(|| {
format!(
"unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
)
})?;
if normalized == self.permission_mode.as_str() {
println!("{}", format_permissions_report(normalized));
return Ok(false);
}
let previous = self.permission_mode.as_str().to_string();
let session = self.runtime.session().clone();
self.permission_mode = permission_mode_from_label(normalized);
self.runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
println!(
"{}",
format_permissions_switch_report(&previous, normalized)
);
Ok(true)
}
fn clear_session(&mut self, confirm: bool) -> Result<bool, Box<dyn std::error::Error>> {
if !confirm {
println!(
"clear: confirmation required; run /clear --confirm to start a fresh session."
);
return Ok(false);
}
self.session = create_managed_session_handle()?;
self.runtime = build_runtime(
Session::new(),
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
println!(
"Session cleared
Mode fresh session
Preserved model {}
Permission mode {}
Session {}",
self.model,
self.permission_mode.as_str(),
self.session.id,
);
Ok(true)
}
fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
println!("{}", format_cost_report(cumulative));
}
fn resume_session(
&mut self,
session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else {
println!("Usage: /resume <session-path>");
return Ok(false);
};
let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
self.runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.session = handle;
println!(
"{}",
format_resume_report(
&self.session.path.display().to_string(),
message_count,
self.runtime.usage().turns(),
)
);
Ok(true)
}
fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_config_report(section)?);
Ok(())
}
fn print_memory() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_memory_report()?);
Ok(())
}
fn print_diff() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_diff_report()?);
Ok(())
}
fn print_version() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_version_report());
Ok(())
}
fn export_session(
&self,
requested_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let export_path = resolve_export_path(requested_path, self.runtime.session())?;
fs::write(&export_path, render_export_text(self.runtime.session()))?;
println!(
"Export
Result wrote transcript
File {}
Messages {}",
export_path.display(),
self.runtime.session().messages.len(),
);
Ok(())
}
fn handle_session_command(
&mut self,
action: Option<&str>,
target: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
match action {
None | Some("list") => {
println!("{}", render_session_list(&self.session.id)?);
Ok(false)
}
Some("switch") => {
let Some(target) = target else {
println!("Usage: /session switch <session-id>");
return Ok(false);
};
let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len();
self.runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.session = handle;
println!(
"Session switched
Active session {}
File {}
Messages {}",
self.session.id,
self.session.path.display(),
message_count,
);
Ok(true)
}
Some(other) => {
println!("Unknown /session action '{other}'. Use /session list or /session switch <session-id>.");
Ok(false)
}
}
}
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig::default());
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
self.runtime = build_runtime(
result.compacted_session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.persist_session()?;
println!("{}", format_compact_report(removed, kept, skipped));
Ok(())
}
fn compress(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1, });
let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
self.runtime = build_runtime(
result.compacted_session,
self.model.clone(),
self.system_prompt.clone(),
true,
true,
self.allowed_tools.clone(),
self.permission_mode,
)?;
self.persist_session()?;
if skipped {
println!("Compression skipped: session is empty or too short.");
} else {
println!(
"Aggressively compressed {} messages. Albert's memory is now lean and sharp ({} kept).",
removed, kept
);
}
Ok(())
}
fn run_auth(&mut self, arg: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
const KNOWN_PROVIDERS: &[&str] = &[
"openai", "anthropic", "google", "xai", "huggingface", "ternlang", "azure", "aws", "ollama",
];
const PROVIDER_MENU: &[&str] = &[
"Anthropic (Claude)", "Google (Gemini)", "OpenAI (GPT-4o)",
"XAI (Grok)", "HuggingFace", "Ollama (local, no key)", "Ternlang",
];
const PROVIDER_KEYS: &[&str] = &[
"anthropic", "google", "openai", "xai", "huggingface", "ollama", "ternlang",
];
if let Some(key) = arg {
if !KNOWN_PROVIDERS.contains(&key.to_lowercase().as_str()) && key.len() > 8 {
let provider = detect_provider_from_key(key).unwrap_or("ternlang");
runtime::save_provider_config(provider, runtime::ProviderConfig {
api_key: Some(key.to_string()),
model: None,
base_url: None,
})?;
println!(" ✓ {} key saved.", provider_display_name(provider));
return Ok(());
}
}
let provider = match arg.map(|s| s.to_lowercase()) {
Some(ref p) if KNOWN_PROVIDERS.contains(&p.as_str()) => p.clone(),
_ => {
let sel = Select::new()
.with_prompt(" Choose provider")
.items(PROVIDER_MENU)
.default(0)
.interact()?;
PROVIDER_KEYS[sel].to_string()
}
};
if provider == "ollama" {
runtime::save_provider_config("ollama", runtime::ProviderConfig {
api_key: None,
model: None,
base_url: Some("http://localhost:11434".to_string()),
})?;
println!(" ✓ Ollama (local) configured — no key needed.");
return Ok(());
}
print!(" Paste API key: ");
io::stdout().flush()?;
let mut api_key = String::new();
io::stdin().read_line(&mut api_key)?;
let api_key = api_key.trim().to_string();
if api_key.is_empty() {
println!(" Error: key cannot be empty.");
return Ok(());
}
runtime::save_provider_config(&provider, runtime::ProviderConfig {
api_key: Some(api_key),
model: None,
base_url: None,
})?;
println!(" ✓ {} key saved.", provider_display_name(&provider));
Ok(())
}
fn run_internal_prompt_text(
&self,
prompt: &str,
enable_tools: bool,
) -> Result<String, Box<dyn std::error::Error>> {
let session = self.runtime.session().clone();
let mut runtime = build_runtime(
session,
self.model.clone(),
self.system_prompt.clone(),
enable_tools,
false,
self.allowed_tools.clone(),
self.permission_mode,
)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode, false);
let summary = runtime.run_turn(prompt.to_string(), Some(&mut permission_prompter))?;
Ok(final_assistant_text(&summary).trim().to_string())
}
fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let scope = scope.unwrap_or("the current repository");
let prompt = format!(
"You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed."
);
println!("{}", self.run_internal_prompt_text(&prompt, true)?);
Ok(())
}
fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let task = task.unwrap_or("the current repo work");
let prompt = format!(
"You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed."
);
println!("{}", self.run_internal_prompt_text(&prompt, true)?);
Ok(())
}
fn run_teleport(&self, target: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
println!("Usage: /teleport <symbol-or-path>");
return Ok(());
};
println!("{}", render_teleport_report(target)?);
Ok(())
}
fn run_debug_tool_call(&self) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_last_tool_debug_report(self.runtime.session())?);
Ok(())
}
fn run_commit(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let status = git_output(&["status", "--short"])?;
if status.trim().is_empty() {
println!("Commit
Result skipped
Reason no workspace changes");
return Ok(());
}
git_status_ok(&["add", "-A"])?;
let staged_stat = git_output(&["diff", "--cached", "--stat"])?;
let prompt = format!(
"Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:
{}
Recent conversation context:
{}",
truncate_for_prompt(&staged_stat, 8_000),
recent_user_context(self.runtime.session(), 6)
);
let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
if message.trim().is_empty() {
return Err("generated commit message was empty".into());
}
let path = write_temp_text_file("claw-commit-message.txt", &message)?;
let output = Command::new("git")
.args(["commit", "--file"])
.arg(&path)
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git commit failed: {stderr}").into());
}
println!(
"Commit
Result created
Message file {}
{}",
path.display(),
message.trim()
);
Ok(())
}
fn run_pr(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let staged = git_output(&["diff", "--stat"])?;
let prompt = format!(
"Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:
TITLE: <title>
BODY:
<body markdown>
Context hint: {}
Diff summary:
{}",
context.unwrap_or("none"),
truncate_for_prompt(&staged, 10_000)
);
let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
let (title, body) = parse_titled_body(&draft)
.ok_or_else(|| "failed to parse generated PR title/body".to_string())?;
if command_exists("gh") {
let body_path = write_temp_text_file("claw-pr-body.md", &body)?;
let output = Command::new("gh")
.args(["pr", "create", "--title", &title, "--body-file"])
.arg(&body_path)
.current_dir(env::current_dir()?)
.output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!(
"PR
Result created
Title {title}
URL {}",
if stdout.is_empty() { "<unknown>" } else { &stdout }
);
return Ok(());
}
}
println!("PR draft
Title {title}
{body}");
Ok(())
}
fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let prompt = format!(
"Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:
TITLE: <title>
BODY:
<body markdown>
Context hint: {}
Conversation context:
{}",
context.unwrap_or("none"),
truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000)
);
let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?);
let (title, body) = parse_titled_body(&draft)
.ok_or_else(|| "failed to parse generated issue title/body".to_string())?;
if command_exists("gh") {
let body_path = write_temp_text_file("claw-issue-body.md", &body)?;
let output = Command::new("gh")
.args(["issue", "create", "--title", &title, "--body-file"])
.arg(&body_path)
.current_dir(env::current_dir()?)
.output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!(
"Issue
Result created
Title {title}
URL {}",
if stdout.is_empty() { "<unknown>" } else { &stdout }
);
return Ok(());
}
}
println!("Issue draft
Title {title}
{body}");
Ok(())
}
fn run_loop(&mut self, mission: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let mission_text = mission.unwrap_or("Complete the current objective.").to_string();
println!("\n{} {}", style("🚀 MISSION STARTED:").bold().green(), style(&mission_text).cyan());
println!("{}", style(" Press Ctrl+C once to abort after the current turn completes.").dim());
self.stop_flag.store(false, Ordering::SeqCst);
let flag = Arc::clone(&self.stop_flag);
ctrlc::set_handler(move || {
flag.store(true, Ordering::SeqCst);
})
.ok();
let mut turn_count = 0;
let max_turns = 10;
loop {
if self.stop_flag.load(Ordering::SeqCst) {
println!("\n{} Autopilot aborted by user after turn {}.", style("⛔ LOOP ABORTED").bold().red(), turn_count);
self.stop_flag.store(false, Ordering::SeqCst);
break;
}
turn_count += 1;
if turn_count > max_turns {
println!("\n{} Maximum turn limit reached ({}). Pausing autopilot for alignment.", style("⚠️").yellow(), max_turns);
break;
}
println!("\n{} [Iteration {}/{}]", style("🌀 Autopilot").bold().magenta(), turn_count, max_turns);
let loop_prompt = format!(
"MISSION: {}\n\nContinue executing the mission. If the mission is fully complete, tested, and validated, end your response with 'MISSION COMPLETE'. Otherwise, continue with the next logical step. Spawn internal swarm reasoning if needed.",
mission_text
);
self.run_turn(&loop_prompt)?;
let last_message = self.runtime.session().messages.last();
if let Some(msg) = last_message {
let content = msg.blocks.iter()
.filter_map(|b| if let ContentBlock::Text { text } = b { Some(text.as_str()) } else { None })
.collect::<Vec<_>>()
.join(" ");
if content.contains("MISSION COMPLETE") {
println!("\n{} Mission objectives achieved. Harness complete.", style("✨ MISSION SUCCESSFUL").bold().green());
break;
}
}
thread::sleep(Duration::from_millis(500));
}
Ok(())
}
fn run_plan(&mut self, task: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let task = task.unwrap_or("").trim().to_string();
let task = if task.is_empty() {
"the current objective based on our conversation".to_string()
} else {
task
};
println!("\n{}", style("🗺 Generating plan...").bold().cyan());
let plan_prompt = format!(
"Create a precise numbered execution plan for: {task}\n\
Format each step as: N. <action>\n\
Include verification steps. Keep steps concrete and actionable. Max 8 steps.\n\
Output ONLY the numbered steps, no preamble.",
);
let plan_text = self.run_internal_prompt_text(&plan_prompt, false)?;
println!("\n{}\n{}", style("Plan").bold().green(), &plan_text);
let steps = parse_numbered_steps(&plan_text);
if steps.is_empty() {
return self.run_turn(&format!("Execute this plan for: {task}\n\n{plan_text}"));
}
println!("\n{} {} steps", style("Executing").bold().yellow(), steps.len());
for (i, step) in steps.iter().enumerate() {
println!("\n{} [{}/{}] {}", style("▶").bold().cyan(), i + 1, steps.len(), step);
self.run_turn(step)?;
}
println!("\n{}", style("✓ Plan complete").bold().green());
Ok(())
}
}
fn parse_numbered_steps(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.len() < 3 { return None; }
if !trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { return None; }
let after_num = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
let after_sep = after_num.trim_start_matches(|c: char| c == '.' || c == ')' || c == ':').trim();
if after_sep.is_empty() { None } else { Some(after_sep.to_string()) }
})
.collect()
}
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let path = cwd.join(".claw").join("sessions");
fs::create_dir_all(&path)?;
Ok(path)
}
fn create_managed_session_handle() -> Result<SessionHandle, Box<dyn std::error::Error>> {
let id = generate_session_id();
let path = sessions_dir()?.join(format!("{id}.json"));
Ok(SessionHandle { id, path })
}
fn generate_session_id() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
format!("session-{millis}")
}
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let direct = PathBuf::from(reference);
let path = if direct.exists() {
direct
} else {
sessions_dir()?.join(format!("{reference}.json"))
};
if !path.exists() {
return Err(format!("session not found: {reference}").into());
}
let id = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(reference)
.to_string();
Ok(SessionHandle { id, path })
}
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
let mut sessions = Vec::new();
for entry in fs::read_dir(sessions_dir()?)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let metadata = entry.metadata()?;
let modified_epoch_secs = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
.unwrap_or_default();
let message_count = Session::load_from_path(&path)
.map(|session| session.messages.len())
.unwrap_or_default();
let id = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string();
sessions.push(ManagedSessionSummary {
id,
path,
modified_epoch_secs,
message_count,
});
}
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
Ok(sessions)
}
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions()?;
let mut lines = vec![
"Sessions".to_string(),
format!(" Directory {}", sessions_dir()?.display()),
];
if sessions.is_empty() {
lines.push(" No managed sessions saved yet.".to_string());
return Ok(lines.join("
"));
}
for session in sessions {
let marker = if session.id == active_session_id {
"● current"
} else {
"○ saved"
};
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
id = session.id,
msgs = session.message_count,
modified = session.modified_epoch_secs,
path = session.path.display(),
));
}
Ok(lines.join("
"))
}
fn render_repl_help() -> String {
[
"REPL".to_string(),
" /exit Quit the REPL".to_string(),
" /quit Quit the REPL".to_string(),
" Up/Down Navigate prompt history".to_string(),
" Tab Complete slash commands".to_string(),
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
String::new(),
render_slash_command_help(),
]
.join(
"
",
)
}
fn status_context(
session_path: Option<&Path>,
) -> Result<StatusContext, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len();
let runtime_config = loader.load()?;
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
Ok(StatusContext {
cwd,
session_path: session_path.map(Path::to_path_buf),
loaded_config_files: runtime_config.loaded_entries().len(),
discovered_config_files,
memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
})
}
fn format_status_report(
model: &str,
usage: StatusUsage,
permission_mode: &str,
context: &StatusContext,
) -> String {
[
format!(
"Status
Model {model}
Permission mode {permission_mode}
Messages {}
Turns {}
Estimated tokens {}",
usage.message_count, usage.turns, usage.estimated_tokens,
),
format!(
"Usage
Latest total {}
Cumulative input {}
Cumulative output {}
Cumulative total {}",
usage.latest.total_tokens(),
usage.cumulative.input_tokens,
usage.cumulative.output_tokens,
usage.cumulative.total_tokens(),
),
format!(
"Workspace
Cwd {}
Project root {}
Git branch {}
Session {}
Config files loaded {}/{}
Memory files {}",
context.cwd.display(),
context
.project_root
.as_ref()
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
context.git_branch.as_deref().unwrap_or("unknown"),
context.session_path.as_ref().map_or_else(
|| "live-repl".to_string(),
|path| path.display().to_string()
),
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
),
]
.join(
"
",
)
}
fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
let mut lines = vec![
format!(
"Config
Working directory {}
Loaded files {}
Merged keys {}",
cwd.display(),
runtime_config.loaded_entries().len(),
runtime_config.merged().len()
),
"Discovered files".to_string(),
];
for entry in discovered {
let source = match entry.source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let status = if runtime_config
.loaded_entries()
.iter()
.any(|loaded_entry| loaded_entry.path == entry.path)
{
"loaded"
} else {
"missing"
};
lines.push(format!(
" {source:<7} {status:<7} {}",
entry.path.display()
));
}
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => runtime_config.get("env"),
"hooks" => runtime_config.get("hooks"),
"model" => runtime_config.get("model"),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, or model."
));
return Ok(lines.join(
"
",
));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join(
"
",
));
}
lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join(
"
",
))
}
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let mut lines = vec![format!(
"Memory
Working directory {}
Instruction files {}",
cwd.display(),
project_context.instruction_files.len()
)];
if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push(
" No TERNLANG instruction files discovered in the current directory ancestry."
.to_string(),
);
} else {
lines.push("Discovered files".to_string());
for (index, file) in project_context.instruction_files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() {
"<empty>"
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
Ok(lines.join(
"
",
))
}
fn init_ternlang_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(initialize_repo(&cwd)?.render())
}
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
init::wake_sequence();
println!("\n{}", init_ternlang_md()?);
Ok(())
}
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"read-only" => Some("read-only"),
"workspace-write" => Some("workspace-write"),
"danger-full-access" => Some("danger-full-access"),
_ => None,
}
}
fn render_diff_report() -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["diff", "--", ":(exclude).omx"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!("git diff failed: {stderr}").into());
}
let diff = String::from_utf8(output.stdout)?;
if diff.trim().is_empty() {
return Ok(
"Diff
Result clean working tree
Detail no current changes"
.to_string(),
);
}
Ok(format!("Diff
{}", diff.trim_end()))
}
fn render_version_report() -> String {
format!(
"Ternlang Code CLI version {}
target: {}
git sha: {}",
VERSION,
BUILD_TARGET.unwrap_or("unknown"),
GIT_SHA.unwrap_or("unknown")
)
}
struct CliPermissionPrompter {
permission_mode: PermissionMode,
interactive: bool,
}
impl CliPermissionPrompter {
fn new(permission_mode: PermissionMode, interactive: bool) -> Self {
Self {
permission_mode,
interactive,
}
}
}
impl runtime::PermissionPrompter for CliPermissionPrompter {
fn decide(
&mut self,
request: &runtime::PermissionRequest,
) -> runtime::PermissionPromptDecision {
let default = match self.permission_mode {
PermissionMode::ReadOnly => runtime::PermissionPromptDecision::Deny {
reason: "read-only mode".to_string(),
},
PermissionMode::WorkspaceWrite => {
if request.tool_name.starts_with("shell") {
runtime::PermissionPromptDecision::Deny {
reason: "shell tools disabled in workspace-write mode".to_string(),
}
} else {
runtime::PermissionPromptDecision::Allow
}
}
PermissionMode::DangerFullAccess => return runtime::PermissionPromptDecision::Allow,
PermissionMode::Allow => return runtime::PermissionPromptDecision::Allow,
PermissionMode::Prompt => runtime::PermissionPromptDecision::Allow,
};
if !self.interactive || !matches!(default, runtime::PermissionPromptDecision::Allow) {
return default;
}
let tool_input_string = request.input.to_string();
if tool_input_string.len() > 256 {
println!(
"Request to use tool `{}` with large input. Preview:\n{}...",
request.tool_name,
&tool_input_string[..256]
);
} else {
println!("Request to use tool `{}` with input:\n{}", request.tool_name, request.input);
}
loop {
let choice = dialoguer::Select::new()
.with_prompt("Allow this tool use?")
.items(&["Allow once", "Deny"])
.default(0)
.interact_opt()
.unwrap_or_default();
match choice {
Some(0) => return runtime::PermissionPromptDecision::Allow,
Some(1) => {
return runtime::PermissionPromptDecision::Deny {
reason: "user denied".to_string(),
}
}
Some(_) => {
return runtime::PermissionPromptDecision::Deny {
reason: "invalid choice".to_string(),
}
}
None => {
return runtime::PermissionPromptDecision::Deny {
reason: "user cancelled".to_string(),
};
}
}
}
}
}
fn detect_provider_from_key(key: &str) -> Option<&'static str> {
if key.starts_with("sk-ant-") {
Some("anthropic")
} else if key.starts_with("AIza") {
Some("google")
} else if key.starts_with("sk-") {
Some("openai")
} else if key.starts_with("xai-") {
Some("xai")
} else if key.starts_with("hf_") {
Some("huggingface")
} else {
None
}
}
fn provider_display_name(provider: &str) -> &str {
match provider {
"anthropic" => "Anthropic (Claude)",
"google" => "Google (Gemini)",
"openai" => "OpenAI",
"xai" => "XAI (Grok)",
"huggingface" => "HuggingFace",
"ollama" => "Ollama",
_ => "Ternlang",
}
}
fn auto_select_model(model: &str) -> String {
if model != DEFAULT_MODEL {
return model.to_string();
}
let gemini_set = std::env::var("GEMINI_API_KEY").ok().filter(|v| !v.is_empty()).is_some()
|| std::env::var("GOOGLE_API_KEY").ok().filter(|v| !v.is_empty()).is_some();
if gemini_set {
return model.to_string();
}
if let Some((_, fallback_model)) = api::detect_provider_and_model_from_env() {
return fallback_model.to_string();
}
model.to_string()
}
fn resolve_provider_for_model(model: &str) -> api::LlmProvider {
if model.contains("gpt-") || model.contains("o1-") || model.contains("o3-") {
api::LlmProvider::OpenAi
} else if model.contains("claude-") || model.contains("opus") || model.contains("sonnet") || model.contains("haiku") {
api::LlmProvider::Anthropic
} else if model.contains("gemini-") {
api::LlmProvider::Google
} else if model.contains("llama") || model.contains("mistral") {
api::LlmProvider::HuggingFace
} else {
api::LlmProvider::Ternlang
}
}
fn build_runtime(
session: Session,
model: String,
system_prompt: Vec<String>,
enable_tools: bool,
enable_stream_events: bool,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<ConversationRuntime<TernlangRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
let cwd = env::current_dir()?;
let _config = ConfigLoader::default_for(&cwd).load()?;
let provider = resolve_provider_for_model(&model);
let provider_config = runtime::load_provider_config(match provider {
api::LlmProvider::Anthropic => "anthropic",
api::LlmProvider::Google => "google",
api::LlmProvider::OpenAi => "openai",
api::LlmProvider::Xai => "xai",
api::LlmProvider::HuggingFace => "huggingface",
api::LlmProvider::Ollama => "ollama",
_ => "ternlang",
}).unwrap_or(None);
let auth_source = if let Some(config) = provider_config {
if let Some(key) = config.api_key {
api::AuthSource::ApiKey(key)
} else {
api::resolve_auth_for_provider(provider).unwrap_or(api::AuthSource::None)
}
} else {
api::resolve_auth_for_provider(provider).unwrap_or(api::AuthSource::None)
};
let client = TernlangClient::from_auth(auth_source).with_provider(provider);
let api_client = TernlangRuntimeClient {
client,
model: model.clone(),
max_tokens: max_tokens_for_model(&model),
tools: if enable_tools {
filter_tool_specs(allowed_tools.as_ref())
} else {
Vec::new()
},
event_tx: if enable_stream_events {
Some(
tokio::runtime::Runtime::new()?
.block_on(async {
let (tx, _) = tokio::sync::broadcast::channel(128);
tx
}),
)
} else {
None
},
};
let tool_executor = CliToolExecutor::new();
let permission_policy = PermissionPolicy::new(permission_mode);
Ok(ConversationRuntime::new(
session,
api_client,
tool_executor,
permission_policy,
system_prompt,
))
}
fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(load_system_prompt(
cwd,
DEFAULT_DATE.to_string(),
env::consts::OS,
"unknown",
)?)
}
#[derive(Clone)]
struct TernlangRuntimeClient {
client: TernlangClient,
model: String,
max_tokens: u32,
tools: Vec<ToolSpec>,
event_tx: Option<tokio::sync::broadcast::Sender<AssistantEvent>>,
}
impl ApiClient for TernlangRuntimeClient {
fn stream(
&mut self,
request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| RuntimeError::new(e.to_string()))?;
runtime.block_on(self.stream_async(request)).map_err(|e| RuntimeError::new(e.to_string()))
}
}
impl TernlangRuntimeClient {
async fn stream_async(
&mut self,
request: ApiRequest,
) -> Result<Vec<AssistantEvent>, Box<dyn std::error::Error + Send + Sync>> {
let mut stream = self
.client
.stream_message(&MessageRequest {
model: self.model.clone(),
max_tokens: Some(self.max_tokens),
messages: request
.messages
.into_iter()
.map(map_conversation_message)
.collect(),
system: Some(request.system_prompt.join("
")),
tools: if self.tools.is_empty() {
None
} else {
Some(self.tools.iter().map(map_tool_spec).collect())
},
tool_choice: if self.tools.is_empty() {
None
} else {
Some(ToolChoice::Auto)
},
stream: true,
})
.await?;
let mut events = Vec::new();
while let Some(event) = stream.next_event().await? {
if let Some(mapped) = map_stream_event(&event) {
if let Some(tx) = &self.event_tx {
let _ = tx.send(mapped.clone());
}
events.push(mapped);
}
}
Ok(events)
}
}
fn map_conversation_message(message: ConversationMessage) -> InputMessage {
InputMessage {
role: match message.role {
MessageRole::User => "user".to_string(),
MessageRole::Assistant => "assistant".to_string(),
MessageRole::Tool => "user".to_string(),
MessageRole::System => "system".to_string(),
},
content: message
.blocks
.into_iter()
.map(map_content_block)
.collect(),
}
}
fn map_content_block(block: ContentBlock) -> InputContentBlock {
match block {
ContentBlock::Text { text } => InputContentBlock::Text { text },
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id, name,
input: serde_json::from_str(&input).unwrap_or(serde_json::Value::Null),
},
ContentBlock::ToolResult { tool_use_id, output, tool_name: _, is_error: _ } => InputContentBlock::ToolResult {
tool_use_id,
content: vec![ToolResultContentBlock::Text { text: output }],
is_error: false,
},
}
}
fn map_tool_spec(spec: &ToolSpec) -> ToolDefinition {
ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema.clone(),
}
}
fn map_stream_event(event: &ApiStreamEvent) -> Option<AssistantEvent> {
match event {
ApiStreamEvent::MessageStart(payload) => Some(AssistantEvent::Usage(map_usage(payload.message.usage.clone()))),
ApiStreamEvent::ContentBlockDelta(payload) => match &payload.delta {
ContentBlockDelta::TextDelta { text } => Some(AssistantEvent::TextDelta(text.clone())),
_ => None,
},
ApiStreamEvent::ContentBlockStart(payload) => match &payload.content_block {
OutputContentBlock::ToolUse { id, name, input } => Some(AssistantEvent::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.to_string(),
}),
_ => None,
},
ApiStreamEvent::MessageDelta(payload) => {
Some(AssistantEvent::Usage(map_usage(payload.usage.clone())))
}
ApiStreamEvent::MessageStop(_) => Some(AssistantEvent::MessageStop),
_ => None,
}
}
fn map_usage(usage: api::Usage) -> TokenUsage {
TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
cache_read_input_tokens: usage.cache_read_input_tokens,
}
}
#[derive(Clone)]
struct CliToolExecutor {}
impl CliToolExecutor {
fn new() -> Self {
Self {}
}
}
impl ToolExecutor for CliToolExecutor {
fn execute(
&mut self,
tool_name: &str,
input: &str,
) -> Result<runtime::ToolResult, ToolError> {
let input_val: serde_json::Value = serde_json::from_str(input).unwrap_or(serde_json::Value::Null);
let preview = tool_call_preview(tool_name, &input_val);
println!("{} {}", style("→").cyan().bold(), style(&preview).dim());
match execute_tool(tool_name, &input_val) {
Ok(res) => Ok(runtime::ToolResult {
output: res.output,
state: res.state,
}),
Err(e) => {
println!("{} {} failed", style("✘").red().bold(), style(tool_name).dim());
Err(ToolError::new(e))
}
}
}
fn query_memory(&mut self, query: &str) -> Result<String, ToolError> {
let input = json!({
"action": "search_nodes",
"query": query
});
match execute_tool("Memory", &input) {
Ok(res) => Ok(res.output),
Err(e) => Err(ToolError::new(e)),
}
}
}
fn typewriter_print(text: &str) {
use std::io::Write;
let stdout = std::io::stdout();
let mut out = stdout.lock();
let delay = if text.len() > 800 { 1 } else if text.len() > 300 { 2 } else { 3 };
for ch in text.chars() {
let _ = write!(out, "{ch}");
let _ = out.flush();
thread::sleep(Duration::from_millis(delay));
}
println!();
}
fn score_turn_importance(user_input: &str, response: &str) -> f32 {
let combined = format!("{user_input} {response}").to_lowercase();
let mut score: f32 = 0.0;
for kw in &["remember this", "remember:", "rule:", "never do", "always do",
"from now on", "important:", "note:", "my name is", "i prefer"] {
if combined.contains(kw) { score += 1.0; break; }
}
for kw in &["my email", "my api key", "my github", "the project is",
"deadline", "we use", "the team", "our stack"] {
if combined.contains(kw) { score += 0.7; break; }
}
if response.len() > 400 && (response.contains("successfully") || response.contains("complete")) {
score += 0.3;
}
if combined.contains("you were wrong") || combined.contains("that's incorrect")
|| combined.contains("you made a mistake") {
score += 0.5; }
score.clamp(0.0, 1.0)
}
fn distill_memory_summary(user_input: &str, response: &str) -> String {
let trigger_kws = ["remember", "rule:", "never", "always", "from now on",
"important:", "note:", "my name", "i prefer", "deadline",
"we use", "the project", "you were wrong", "that's incorrect"];
let user_lower = user_input.to_lowercase();
for kw in &trigger_kws {
if user_lower.contains(kw) {
if let Some(sentence) = user_input.split('.').find(|s| s.to_lowercase().contains(kw)) {
return sentence.trim().to_string();
}
}
}
let trimmed = user_input.trim();
if trimmed.len() > 120 { trimmed[..120].to_string() } else { trimmed.to_string() }
}
fn append_to_albert_memory(summary: &str) -> std::io::Result<()> {
let memory_path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ternlang")
.join("memory.md");
if let Some(parent) = memory_path.parent() {
fs::create_dir_all(parent)?;
}
if !memory_path.exists() {
fs::write(&memory_path, "# Albert Memory\n\nAutomatic self-reflection log.\n\n")?;
}
let date = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string();
let entry = format!("- **{date}**: {summary}\n");
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&memory_path)?;
use std::io::Write;
file.write_all(entry.as_bytes())
}
fn extract_image_attachments(input: &str) -> (String, Vec<String>) {
let mut paths = Vec::new();
let mut cleaned = input.to_string();
let patterns = &["[image: ", "[img: ", "[attach: "];
for prefix in patterns {
loop {
let Some(start) = cleaned.find(prefix) else { break };
let rest = &cleaned[start + prefix.len()..];
let Some(end_offset) = rest.find(']') else { break };
let path = rest[..end_offset].trim().to_string();
let full_tag = format!("{}{}{}", prefix, path, "]");
paths.push(path);
cleaned = cleaned.replacen(&full_tag, "", 1);
}
}
(cleaned.trim().to_string(), paths)
}
fn load_image_as_base64(path: &str) -> Result<(String, String), String> {
use base64::{Engine as _, engine::general_purpose::STANDARD};
let data = fs::read(path).map_err(|e| format!("cannot read image {path}: {e}"))?;
let mime = if path.ends_with(".png") { "image/png" }
else if path.ends_with(".jpg") || path.ends_with(".jpeg") { "image/jpeg" }
else if path.ends_with(".gif") { "image/gif" }
else if path.ends_with(".webp") { "image/webp" }
else { "image/png" };
Ok((mime.to_string(), STANDARD.encode(&data)))
}
fn parse_retry_after(error_str: &str) -> Option<u64> {
let lower = error_str.to_lowercase();
for pattern in &["retrydelay\":\"", "retry in "] {
if let Some(pos) = lower.find(pattern) {
let rest = &lower[pos + pattern.len()..];
let num_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = num_str.parse::<u64>() {
return Some(n);
}
}
}
None
}
fn fallback_model_for(model: &str) -> Option<&'static str> {
let fallbacks: &[(&str, &str)] = &[
("gemini-2.5-flash", "gemini-2.5-pro-preview"),
("gemini-2.5-pro-preview", "gemini-2.5-flash"),
("gemini-2.0-flash", "gemini-2.5-flash"),
("gpt-4o", "gpt-4o-mini"),
("claude-opus-4-7", "claude-sonnet-4-6"),
("claude-sonnet-4-6", "claude-haiku-4-5"),
];
fallbacks.iter().find(|(from, _)| *from == model).map(|(_, to)| *to)
}
fn clean_error_message(raw: &str) -> String {
if let Some(start) = raw.find("\"message\":") {
let rest = &raw[start + 10..].trim_start();
let rest = rest.trim_start_matches('"');
let msg: String = rest.chars().take_while(|&c| c != '"').collect();
if !msg.is_empty() {
let first_line = msg.lines().next().unwrap_or(&msg);
return first_line.trim_end_matches('.').to_string();
}
}
raw.lines().next().unwrap_or(raw).to_string()
}
fn tool_call_preview(tool_name: &str, input: &serde_json::Value) -> String {
let key_args = ["path", "command", "pattern", "url", "query", "file_path"];
for key in &key_args {
if let Some(val) = input.get(key).and_then(|v| v.as_str()) {
let truncated = if val.len() > 60 { &val[..60] } else { val };
return format!("{tool_name}({key}: {truncated:?})");
}
}
format!("{tool_name}()")
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary
.assistant_messages
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("
")
}
fn collect_tool_uses(summary: &runtime::TurnSummary) -> serde_json::Value {
serde_json::Value::Array(
summary
.assistant_messages
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => Some(json!({
"id": id,
"name": name,
"input": input,
})),
_ => None,
})
.collect(),
)
}
fn collect_tool_results(summary: &runtime::TurnSummary) -> serde_json::Value {
serde_json::Value::Array(
summary
.tool_results
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolResult { tool_use_id, output, tool_name: _, is_error: _ } => Some(json!({
"tool_use_id": tool_use_id,
"output": output,
})),
_ => None,
})
.collect(),
)
}
fn slash_command_completion_candidates() -> Vec<String> {
let mut candidates: Vec<String> = slash_command_specs()
.iter()
.map(|spec| format!("/{}", spec.name))
.collect();
candidates.sort();
candidates
}
struct ModelEntry {
id: &'static str,
provider: &'static str,
description: &'static str,
}
const KNOWN_MODELS: &[ModelEntry] = &[
ModelEntry { id: "gemini-2.5-pro-preview", provider: "Google", description: "Most capable Gemini — complex reasoning" },
ModelEntry { id: "gemini-2.5-flash", provider: "Google", description: "Fast & capable — recommended default" },
ModelEntry { id: "gemini-2.5-flash-lite", provider: "Google", description: "Lightest Gemini — maximum speed" },
ModelEntry { id: "gemini-2.0-flash", provider: "Google", description: "Previous Flash generation" },
ModelEntry { id: "claude-opus-4-7", provider: "Anthropic", description: "Most capable Claude" },
ModelEntry { id: "claude-sonnet-4-6", provider: "Anthropic", description: "Best balance of speed and capability" },
ModelEntry { id: "claude-haiku-4-5", provider: "Anthropic", description: "Fastest Claude" },
ModelEntry { id: "gpt-4o", provider: "OpenAI", description: "GPT-4o multimodal flagship" },
ModelEntry { id: "gpt-4o-mini", provider: "OpenAI", description: "Efficient GPT-4o variant" },
ModelEntry { id: "o3-mini", provider: "OpenAI", description: "o3 reasoning — efficient" },
ModelEntry { id: "grok-3", provider: "xAI", description: "Grok 3 flagship" },
ModelEntry { id: "grok-3-mini", provider: "xAI", description: "Efficient Grok variant" },
ModelEntry { id: "llama-3.3-70b-versatile", provider: "Ollama", description: "Llama 3.3 70B — local or hosted" },
];
fn show_model_picker(current_model: &str) -> Option<String> {
let items: Vec<String> = KNOWN_MODELS.iter().map(|m| {
let active = if m.id == current_model { style("●").green().to_string() } else { " ".to_string() };
format!("{} {:<32} {:>12} {}",
active,
style(m.id).bold(),
style(format!("({})", m.provider)).dim(),
style(m.description).dim(),
)
}).collect();
let current_idx = KNOWN_MODELS.iter().position(|m| m.id == current_model).unwrap_or(0);
println!("\n{}", style("Select Model").bold().cyan());
println!("{}", style("─".repeat(72)).dim());
let result = dialoguer::Select::new()
.items(&items)
.default(current_idx)
.interact_opt()
.unwrap_or(None)?;
let chosen = KNOWN_MODELS[result].id;
if chosen == current_model {
return None;
}
Some(chosen.to_string())
}
fn show_slash_picker(filter: &str) -> Option<String> {
let filter_str = filter.trim_start_matches('/').to_lowercase();
let specs: Vec<&commands::SlashCommandSpec> = slash_command_specs()
.iter()
.filter(|s| filter_str.is_empty() || s.name.starts_with(filter_str.as_str()))
.collect();
if specs.is_empty() {
return None;
}
let items: Vec<String> = specs.iter().map(|s| {
let hint = s.argument_hint.map(|h| format!(" {h}")).unwrap_or_default();
format!("{:<22} {}",
style(format!("/{}{}", s.name, hint)).cyan().bold(),
s.summary,
)
}).collect();
println!("\n{}", style("─".repeat(60)).dim());
let result = dialoguer::Select::new()
.items(&items)
.default(0)
.interact_opt()
.unwrap_or(None)?;
Some(format!("/{}", specs[result].name))
}
fn print_status_bar(model: &str, session_id: &str, msg_count: usize) {
let model_part = style(format!("model:{model}")).cyan();
let session_part = style(format!("session:{}", &session_id[..session_id.len().min(18)])).dim();
let msgs_part = style(format!("msgs:{msg_count}")).dim();
let hint_part = style("/ for commands").dim();
println!("{model_part} {session_part} {msgs_part} {hint_part}");
}
fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
if value.len() <= max_chars {
return value.to_string();
}
let mut truncated = String::with_capacity(max_chars + 20);
truncated.push_str(&value[..max_chars]);
truncated.push_str("
... (truncated)");
truncated
}
fn write_temp_text_file(name: &str, content: &str) -> Result<PathBuf, io::Error> {
let path = env::temp_dir().join(name);
fs::write(&path, content)?;
Ok(path)
}
fn command_exists(name: &str) -> bool {
let Ok(paths) = env::var("PATH") else {
return false;
};
paths
.split(':')
.map(|path| Path::new(path).join(name))
.any(|path| path.exists())
}
fn sanitize_generated_message(message: &str) -> String {
let message = message.trim();
if let Some(stripped) = message.strip_prefix("```text
") {
return stripped.strip_suffix("```").unwrap_or(stripped).to_string();
}
if let Some(stripped) = message.strip_prefix("```") {
return stripped.strip_suffix("```").unwrap_or(stripped).to_string();
}
message.to_string()
}
fn parse_titled_body(raw: &str) -> Option<(String, String)> {
let Some(title_start) = raw.find("TITLE:") else {
return None;
};
let Some(body_start) = raw.find("BODY:") else {
return None;
};
let title = raw[title_start + 6..body_start].trim().to_string();
let body = raw[body_start + 5..].trim().to_string();
Some((title, body))
}
fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.output()?;
Ok(String::from_utf8(output.stdout)?)
}
fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
let status = std::process::Command::new("git")
.args(args)
.current_dir(env::current_dir()?)
.status()?;
if !status.success() {
return Err("git command failed".into());
}
Ok(())
}
fn recent_user_context(session: &Session, max_messages: usize) -> String {
session
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.rev()
.take(max_messages)
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("
")
}
fn resolve_export_path(requested_path: Option<&str>, _session: &Session) -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(PathBuf::from(requested_path.unwrap_or("export.txt")))
}
fn render_export_text(_session: &Session) -> String {
"".to_string()
}
fn render_teleport_report(_target: &str) -> Result<String, Box<dyn std::error::Error>> {
Ok("".to_string())
}
fn render_last_tool_debug_report(_session: &Session) -> Result<String, Box<dyn std::error::Error>> {
Ok("".to_string())
}
fn check_workspace_trust() -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("\n{}", style("────────────────────────────────────────────────────────────").dim());
println!("Accessing workspace:");
println!("\n {}\n", style(cwd.display()).cyan());
println!("Quick safety check: Is this a project you created or one you trust?");
println!("(Like your own code, a well-known open source project, or work from your team).");
println!("If not, take a moment to review what's in this folder first.\n");
let options = vec!["Yes, I trust this folder", "No, exit"];
let selection = Select::new()
.with_prompt("Security guide")
.items(&options)
.default(0)
.interact()?;
if selection == 1 {
println!("\nExiting for safety.");
std::process::exit(0);
}
println!("{}", style("Trust verified. Let's build.").dim());
Ok(())
}