use std::collections::{BinaryHeap, HashSet};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use a3s_code_core::config::OsConfig;
use a3s_code_core::context::RecentWorkspaceFilesContextProvider;
use a3s_code_core::hitl::TimeoutAction;
use a3s_code_core::workspace::{
LocalWorkspaceManifest, LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend,
WorkspaceServices,
};
use a3s_code_core::{Agent, AgentEvent, AgentSession, SessionOptions, SystemPromptSlots};
use a3s_tui::cmd::{self, Cmd};
use a3s_tui::components::textarea::TextareaMsg;
use a3s_tui::components::viewport::ViewportMsg;
use a3s_tui::components::{Spinner, Textarea, Viewport};
use a3s_tui::event::KeyEvent;
use a3s_tui::keymap::{KeyBinding, Keymap};
use a3s_tui::layout::{Constraint, Layout};
use a3s_tui::streaming::StreamingMarkdown;
use a3s_tui::style::{Color, Style};
use a3s_tui::{Event, KeyCode, KeyModifiers, Model, ProgramBuilder};
use tokio::sync::{mpsc, Mutex};
use crate::top::{collect_processes, render_process_table, ProcessRow, ProcessTableView};
mod config;
mod gitutil;
mod image;
mod kbutil;
mod memutil;
mod os_im;
mod panels;
mod remote_ui;
mod render;
pub(crate) mod skills;
mod syntax;
mod update;
mod util;
use config::*;
use gitutil::*;
use image::*;
use memutil::*;
use render::*;
use skills::*;
use syntax::*;
use update::*;
use util::*;
const ACCENT: Color = Color::Rgb(122, 162, 247); const TN_GREEN: Color = Color::Rgb(158, 206, 106);
const TN_YELLOW: Color = Color::Rgb(224, 175, 104);
const TN_RED: Color = Color::Rgb(247, 118, 142);
const TN_CYAN: Color = Color::Rgb(125, 207, 255);
const TN_ORANGE: Color = Color::Rgb(255, 158, 100);
const TN_PURPLE: Color = Color::Rgb(187, 154, 247); const TN_FG: Color = Color::Rgb(192, 202, 245); const TN_GRAY: Color = Color::Rgb(122, 132, 168);
fn os_platform_guide(base_url: &str) -> String {
format!(
"[OS platform] You are signed in to the OS open platform at {base_url} (via /login). \
DEFAULT RULE: while signed in, \"OS\" in the user's questions ALWAYS means THIS OS platform — \
never this machine's operating system. So \"what's my OS account\", \"what modules does OS have\", \
etc. are about the platform. Answer them via the platform's progressive API; do NOT answer from \
this machine (whoami / hostname / paths / working directory describe the local box, not the \
platform — they are the WRONG answer). The endpoint and auth token are ALREADY in your shell \
environment (exported at login) — use them directly; do NOT read ~/.a3s/os-auth.json or any config \
file on each call:\n\
curl -s -X POST \"$A3S_OS_BASE_URL/api/v1/kernel/capabilities\" \
-H \"Authorization: Bearer $A3S_OS_TOKEN\" -H 'Content-Type: application/json' \
-d '{{\"action\":\"list\"}}'\n\
Body fields: `action` = list|search|describe|execute, plus `module` / `operation` / `params`. \
Go broad→narrow: `list` (modules) → `describe`/`search` for the one operation → `execute`. \
For `list`/`search`/`describe`, pipe through `jq` to extract only the fields you need so output \
stays a few lines (e.g. `| jq -r '.data.modules[].name'`). \
For `execute`, ALWAYS add `\"shaped\":true` to the request body — that is what makes the response \
carry the `.view` popup deep-link — and do NOT jq-narrow an execute response: pipe it whole (it is \
already compact), so `.view` survives. If you strip `.view` (or omit `\"shaped\":true`), the user \
loses the 查看视图 link. \
Summarize the result for the user in a few lines; do NOT paste the whole raw JSON back. \
After your summary, ALWAYS output the trace on its own line, exactly \
`↳ requestId <requestId> · <timestamp>`. \
You do NOT print the view link yourself: whenever the execute output carries a `.view`, the host \
automatically shows a one-click `🔗 查看视图` line that opens the authenticated 渐进式UI popup \
(the user's OS login is injected, no re-login). Never print the raw URL. The \
`a3s-os-capabilities` skill has full examples."
)
}
const SLASH_COMMANDS: &[(&str, &str)] = &[
(
"/model",
"switch model (←/→ for Claude/GPT accounts if signed in)",
),
("/init", "analyze the project and generate AGENTS.md"),
("/config", "edit config.acl in the built-in editor"),
("/theme", "cycle the code-highlight theme (Atom One Dark …)"),
(
"/workflow",
"view the latest ultracode dynamic workflow (read-only)",
),
(
"/review",
"reopen the review checklist · or pick a local repos project to review",
),
(
"/deploy",
"pick a repos project → Agentic CI/CD → OS gateway (needs /login)",
),
(
"/run",
"pick a repos project → dev-mode debug run on A3S Runtime → access URL (needs /login)",
),
(
"/flow",
"pick a flow DAG → OS workflow designer (edit + debug run, needs /login) · /flow <text> drafts one",
),
(
"/evolve",
"pick a repo → set a goal → multi-round auto-improving dev (needs /login)",
),
(
"/output",
"view every tool call this session (name · args · result)",
),
("/login", "sign in to the configured OS account"),
("/logout", "sign out from the configured OS account"),
("/view", "open the last OS view in a native window"),
("/plugin", "enable/disable Claude skills & plugins"),
("/reload", "re-scan skills/plugins (hot-reload the / menu)"),
("/update", "upgrade a3s to the latest release"),
("/btw", "ask a background side-question (/btw <prompt>)"),
("/top", "live process monitor (highlights coding agents)"),
("/ide", "superfile-style file browser + editor"),
("/im", "OS chat — DMs & groups (standalone; needs /login)"),
("/git", "git status / diff / stage / commit (gitui-style)"),
(
"/memory",
"browse the agent's long-term memory (GitLens-style timeline)",
),
(
"/kb",
"browse/manage the knowledge base · /kb <text|file|folder> adds",
),
(
"/ctx",
"search past sessions (ctx) · /ctx <n> attach · /ctx save <n> keep as memory",
),
("/effort", "adjust model effort (low … max)"),
("/compact", "summarize + compact the conversation context"),
("/goal", "set a north-star goal the agent keeps in mind"),
(
"/loop",
"run a task, auto-continuing until done (Esc stops)",
),
(
"/sleep",
"consolidate today's work into memory (experience · preferences · knowledge)",
),
("/relay", "continue an unfinished task from another agent"),
("/help", "show commands and shortcuts"),
(
"/fork",
"branch a new session from this point (original kept)",
),
("/clear", "reset the conversation"),
("/auto", "switch to auto-approve mode"),
("/exit", "quit a3s code"),
];
const IDLE_ONLY: &[&str] = &[
"/clear", "/compact", "/model", "/effort", "/goal", "/loop", "/relay", "/reload", "/update",
"/init", "/fork", "/review", "/sleep", "/flow",
];
fn slash_candidates(input: &str) -> Vec<(&'static str, &'static str)> {
SLASH_COMMANDS
.iter()
.filter(|(cmd, _)| cmd.starts_with(input))
.copied()
.collect()
}
fn task_status_style(status: a3s_code_core::planning::TaskStatus) -> (char, Color) {
use a3s_code_core::planning::TaskStatus;
match status {
TaskStatus::Completed => ('✔', TN_GRAY),
TaskStatus::InProgress => ('▶', TN_YELLOW),
TaskStatus::Failed => ('✗', TN_RED),
TaskStatus::Skipped | TaskStatus::Cancelled => ('⊘', TN_GRAY),
_ => ('□', TN_GRAY), }
}
fn needs_synthesis(
synthesis_inflight: bool,
synthesis_used: bool,
had_agent_activity: bool,
text_after_activity: bool,
) -> bool {
!synthesis_inflight && !synthesis_used && had_agent_activity && !text_after_activity
}
fn estimate_tokens(s: &str) -> usize {
let (ascii, wide) = s.chars().fold((0usize, 0usize), |(a, w), c| {
if c.is_ascii() {
(a + 1, w)
} else {
(a, w + 1)
}
});
ascii / 4 + wide
}
const DEFAULT_CONTEXT_LIMIT: u32 = 128_000;
const CORE_MAX_CONTEXT_TOKENS: f32 = 200_000.0;
fn resolve_ctx_limit(raw: Option<u32>) -> u32 {
match raw {
Some(c) if c > 0 => c,
_ => DEFAULT_CONTEXT_LIMIT,
}
}
fn auto_compact_threshold_for(window: u32) -> f32 {
let window = if window > 0 {
window as f32
} else {
CORE_MAX_CONTEXT_TOKENS
};
(0.85 * window / CORE_MAX_CONTEXT_TOKENS).clamp(0.01, 1.0)
}
fn ctx_warn_tier(pct: usize, warned: u8) -> (u8, Option<u8>) {
let tier: u8 = if pct >= 85 {
85
} else if pct >= 70 {
70
} else {
0
};
(tier, (tier > warned).then_some(tier))
}
fn workflow_doc_for_tool(name: &str, args: Option<&serde_json::Value>) -> Option<(String, String)> {
match name {
"program" => {
let src = args
.and_then(|a| a.get("source"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
Some((
format!("# Dynamic workflow script\n\n```javascript\n{src}\n```\n"),
"dynamic workflow script · /workflow to view read-only".to_string(),
))
}
"parallel_task" => {
let tasks = args
.and_then(|a| a.get("tasks"))
.and_then(|t| t.as_array())?
.iter()
.collect::<Vec<_>>();
workflow_doc_for_tasks(&tasks, true)
}
"task" => {
let args = args?;
if let Some(tasks) = args.get("tasks").and_then(|t| t.as_array()) {
let tasks = tasks.iter().collect::<Vec<_>>();
workflow_doc_for_tasks(&tasks, tasks.len() > 1)
} else {
workflow_doc_for_tasks(&[args], false)
}
}
_ => None,
}
}
fn workflow_doc_for_tasks(
tasks: &[&serde_json::Value],
parallel: bool,
) -> Option<(String, String)> {
if tasks.is_empty() {
return None;
}
let mut doc = if parallel {
format!(
"# Dynamic workflow\n\nFanned out {} parallel subagent task(s):\n\n",
tasks.len()
)
} else {
"# Dynamic workflow\n\nDelegated subagent task(s):\n\n".to_string()
};
for (i, task) in tasks.iter().enumerate() {
let desc = task
.get("description")
.or_else(|| task.get("prompt"))
.or_else(|| task.get("task"))
.and_then(|v| v.as_str())
.unwrap_or("(task)");
let agent = task
.get("agent")
.and_then(|v| v.as_str())
.unwrap_or("agent");
let prompt = task
.get("prompt")
.or_else(|| task.get("task"))
.and_then(|v| v.as_str())
.unwrap_or("");
doc.push_str(&format!(
"## {}. {desc}\n\nAgent: `{agent}`\n\n{prompt}\n\n",
i + 1
));
}
let label = if parallel {
format!(
"dynamic workflow · {} parallel tasks · /workflow to view read-only",
tasks.len()
)
} else {
format!(
"dynamic workflow · {} delegated task{} · /workflow to view read-only",
tasks.len(),
if tasks.len() == 1 { "" } else { "s" }
)
};
Some((doc, label))
}
fn agent_color(agent: &str) -> Color {
match agent {
"a3s-code" => ACCENT,
"claude code" => Color::Rgb(217, 119, 87), "codex" => Color::Rgb(16, 163, 127), "cursor" => Color::Rgb(180, 182, 200),
"gemini" => Color::Rgb(124, 137, 245),
_ => TN_GRAY,
}
}
async fn fetch_top() -> Vec<ProcessRow> {
collect_processes().await.unwrap_or_default()
}
struct IdeEntry {
path: std::path::PathBuf,
name: String,
depth: usize,
is_dir: bool,
expanded: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum EditMode {
Normal,
Insert,
}
struct IdeFile {
path: std::path::PathBuf,
lines: Vec<String>, scroll: usize, hscroll: usize, row: usize, col: usize, dirty: bool,
image: bool, readonly: bool, mode: EditMode, pending: Option<char>,
undo: Vec<(Vec<String>, usize, usize)>,
clip: String, clip_linewise: bool, }
impl IdeFile {
fn new(path: std::path::PathBuf, lines: Vec<String>, image: bool, readonly: bool) -> Self {
IdeFile {
path,
lines: if lines.is_empty() {
vec![String::new()]
} else {
lines
},
scroll: 0,
hscroll: 0,
row: 0,
col: 0,
dirty: false,
image,
readonly,
mode: EditMode::Normal,
pending: None,
undo: Vec::new(),
clip: String::new(),
clip_linewise: false,
}
}
}
struct ToolCallRecord {
name: String,
args: Option<serde_json::Value>,
output: String,
exit_code: i32,
}
fn format_tool_log_records(records: &[ToolCallRecord]) -> Option<String> {
if records.is_empty() {
return None;
}
let mut out = String::new();
for (i, rec) in records.iter().enumerate() {
if i > 0 {
out.push('\n');
}
let status = if rec.exit_code == 0 {
"ok".to_string()
} else {
format!("exit {}", rec.exit_code)
};
out.push_str(&format!("#{} · {} · {}\n", i + 1, rec.name, status));
if let Some(args) = &rec.args {
out.push_str(&format!(
" args: {}\n",
serde_json::to_string(args).unwrap_or_default()
));
}
let trimmed = rec.output.trim_end();
if !trimmed.is_empty() {
out.push_str(" output:\n");
for line in trimmed.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
Some(out)
}
fn deep_research_prompt(query: &str) -> String {
format!(
"Conduct deep research to answer the query below. Be thorough:\n\
1. Break it into the key sub-questions worth investigating.\n\
2. Use web search across those sub-questions, then read the most relevant \
sources in full with web_fetch — don't rely on result snippets alone.\n\
3. Cross-check claims across multiple independent sources; call out any \
disagreement, uncertainty, or recency caveats.\n\
4. Synthesize a comprehensive, well-structured answer with inline \
citations and a final \"Sources\" list of the URLs you used.\n\n\
Query: {query}"
)
}
fn deep_research_goal(query: &str) -> String {
format!("Deep research — deliver a comprehensive, well-cited report answering: {query}")
}
fn append_scrollbar(view: &str, inner_width: usize, total: usize, scroll_percent: u8) -> String {
let rows: Vec<&str> = view.split('\n').collect();
let h = rows.len();
let overflow = total > h && h > 0;
let thumb_len = if overflow { (h * h / total).max(1) } else { 0 };
let thumb_start = if overflow {
(h - thumb_len) * scroll_percent as usize / 100
} else {
0
};
let track = Style::new().fg(TN_GRAY);
let thumb = Style::new().fg(ACCENT);
rows.iter()
.enumerate()
.map(|(i, row)| {
let bar = if !overflow {
" ".to_string()
} else if i >= thumb_start && i < thumb_start + thumb_len {
thumb.render("█")
} else {
track.render("│")
};
format!("{}{}", pad_to(row, inner_width), bar)
})
.collect::<Vec<_>>()
.join("\n")
}
fn osc52_copy(text: &str) -> String {
use base64::Engine;
let capped: String = text.chars().take(64_000).collect();
let b64 = base64::engine::general_purpose::STANDARD.encode(capped.as_bytes());
format!("\x1b]52;c;{b64}\x07")
}
const VIEW_BUTTON_MARKER: &str = "查看视图";
fn copy_to_clipboard(text: &str) {
use std::io::Write;
let mut out = std::io::stdout();
let _ = out.write_all(osc52_copy(text).as_bytes());
let _ = out.flush();
#[cfg(target_os = "macos")]
{
if let Ok(mut child) = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
}
}
}
const SELECTION_BG: Color = Color::Rgb(58, 64, 88);
#[derive(Clone, Copy)]
struct Selection {
anchor: (u16, u16),
head: (u16, u16),
}
impl Selection {
fn is_empty(&self) -> bool {
self.anchor == self.head
}
fn ordered(&self) -> (usize, usize, usize, usize) {
let (a, b) = if self.anchor <= self.head {
(self.anchor, self.head)
} else {
(self.head, self.anchor)
};
(a.0 as usize, a.1 as usize, b.0 as usize, b.1 as usize)
}
}
fn slice_cols(s: &str, from: usize, to: usize) -> String {
let mut col = 0usize;
let mut out = String::new();
for ch in s.chars() {
if col >= to {
break;
}
if col >= from {
out.push(ch);
}
col += a3s_tui::style::visible_len(&ch.to_string());
}
out
}
fn selection_to_text(view: &str, r1: usize, c1: usize, r2: usize, c2: usize) -> String {
let rows: Vec<&str> = view.split('\n').collect();
let mut out: Vec<String> = Vec::new();
for r in r1..=r2 {
let Some(row) = rows.get(r) else { break };
let plain = a3s_tui::style::strip_ansi(row);
let from = if r == r1 { c1 } else { 0 };
let to = if r == r2 { c2 } else { usize::MAX };
out.push(slice_cols(&plain, from, to).trim_end().to_string());
}
out.join("\n")
}
fn highlight_selection(view: &str, r1: usize, c1: usize, r2: usize, c2: usize) -> String {
let bg = Style::new().bg(SELECTION_BG).fg(TN_FG);
view.split('\n')
.enumerate()
.map(|(i, row)| {
if i < r1 || i > r2 {
return row.to_string();
}
let plain = a3s_tui::style::strip_ansi(row);
let from = if i == r1 { c1 } else { 0 };
let to = if i == r2 { c2 } else { usize::MAX };
let before = slice_cols(&plain, 0, from);
let sel = slice_cols(&plain, from, to);
let after = slice_cols(&plain, to, usize::MAX);
format!("{before}{}{after}", bg.render(&sel))
})
.collect::<Vec<_>>()
.join("\n")
}
struct Ide {
entries: Vec<IdeEntry>,
sel: usize,
tree_scroll: usize,
file: Option<IdeFile>,
focus_editor: bool,
flash: Option<String>,
title: String,
preview: Option<(std::path::PathBuf, Vec<String>)>,
kb_root: Option<std::path::PathBuf>,
armed_delete: Option<std::path::PathBuf>,
}
impl Ide {
fn browse(entries: Vec<IdeEntry>, title: &str) -> Self {
Ide {
entries,
sel: 0,
tree_scroll: 0,
file: None,
focus_editor: false,
flash: None,
title: title.to_string(),
preview: None,
kb_root: None,
armed_delete: None,
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum ChatMode {
Chat,
Search,
}
struct ChatPanel {
convs: Vec<os_im::ImConversation>,
sel: usize,
active: Option<String>,
messages: Vec<os_im::ImMessage>,
me: Option<String>,
error: Option<String>,
loading: bool,
mode: ChatMode,
contacts: Vec<os_im::Contact>,
contact_sel: usize,
}
fn ide_children(dir: &std::path::Path, depth: usize) -> Vec<IdeEntry> {
let mut v: Vec<IdeEntry> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if matches!(
name.as_str(),
".git" | "node_modules" | "target" | ".DS_Store" | ".next" | "dist"
) {
return None;
}
let is_dir = e.path().is_dir();
Some(IdeEntry {
path: e.path(),
name,
depth,
is_dir,
expanded: false,
})
})
.collect();
v.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
v
}
fn project_instructions(workspace: &str) -> Option<String> {
for name in ["CLAUDE.md", "AGENT.md"] {
let p = std::path::Path::new(workspace).join(name);
if let Ok(c) = std::fs::read_to_string(&p) {
if !c.trim().is_empty() {
return Some(format!("# Project Instructions ({name})\n\n{c}"));
}
}
}
None
}
const PAD: usize = 2;
struct EffortProfile {
label: &'static str,
thinking_budget: usize,
max_tool_rounds: usize,
max_continuation_turns: u32,
guideline: Option<&'static str>,
}
const EFFORT_LEVELS: &[EffortProfile] = &[
EffortProfile {
label: "low",
thinking_budget: 1024,
max_tool_rounds: 120,
max_continuation_turns: 2,
guideline: Some(EFFORT_LOW),
},
EffortProfile {
label: "medium",
thinking_budget: 4096,
max_tool_rounds: 200,
max_continuation_turns: 3,
guideline: None,
},
EffortProfile {
label: "high",
thinking_budget: 8192,
max_tool_rounds: 300,
max_continuation_turns: 4,
guideline: Some(EFFORT_HIGH),
},
EffortProfile {
label: "xhigh",
thinking_budget: 16384,
max_tool_rounds: 400,
max_continuation_turns: 6,
guideline: Some(EFFORT_XHIGH),
},
EffortProfile {
label: "max",
thinking_budget: 32768,
max_tool_rounds: 500,
max_continuation_turns: 8,
guideline: Some(EFFORT_MAX),
},
EffortProfile {
label: "ultracode",
thinking_budget: 32768,
max_tool_rounds: 600,
max_continuation_turns: 8,
guideline: Some(ULTRACODE_GUIDELINES),
},
];
const ULTRACODE: usize = 5;
const EFFORT_LOW: &str = "\
[effort: low] Favor speed and minimalism. Answer directly, make the smallest \
change that works (reading enough surrounding code to change it safely), and \
keep verification proportionate: still run the narrowest build/test/type-check \
that covers what you touched — just don't add checks or scope the task didn't \
warrant. Don't gold-plate.";
const EFFORT_HIGH: &str = "\
[effort: high] Favor depth. Reason through the approach before acting. After \
changes, verify the narrow path you touched (build / test / type-check) and \
check the obvious edge cases, then re-read your own diff for correctness before \
finishing.";
const EFFORT_XHIGH: &str = "\
[effort: xhigh] Work rigorously. Before committing to an approach, weigh at \
least one alternative. Verify thoroughly — run the relevant tests/build, probe \
edge cases and failure modes, and confirm the change actually does what was \
asked. Do a self-review pass for correctness and simplicity before concluding.";
const EFFORT_MAX: &str = "\
[effort: max] Maximum rigor; prefer correctness and completeness over speed. \
Decompose the problem, compare alternatives, and implement the strongest \
solution. Verify exhaustively: tests, build, edge cases, and boundary / \
adversarial inputs. Finish with a self-critique pass that actively hunts for \
what you may have missed or gotten wrong, and fix it before concluding.";
const ULTRACODE_GUIDELINES: &str = "\
[ultracode] Dynamic-workflow mode is available — you decide whether a turn needs \
it. Match the effort to the task: answer trivial or conversational input (a \
greeting, a single question, a one-step edit) directly, with no plan and no \
fan-out. When a task genuinely splits into independent branches, decompose it, \
run those branches as parallel background subagents via `parallel_task` (keep \
each child prompt bounded and evidence-oriented), then synthesize their results \
before continuing dependent work.";
struct RelaySession {
agent: &'static str,
native_id: Option<String>,
seed: Option<String>,
label: String,
mtime: std::time::SystemTime,
}
fn parse_user_line(line: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(line).ok()?;
let role = v
.get("message")
.and_then(|m| m.get("role"))
.or_else(|| v.get("payload").and_then(|p| p.get("role")))
.or_else(|| v.get("role"))
.and_then(|r| r.as_str());
if role != Some("user") {
return None;
}
let content = v
.get("message")
.and_then(|m| m.get("content"))
.or_else(|| v.get("payload").and_then(|p| p.get("content")))
.or_else(|| v.get("content"))?;
let txt = match content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(a) => a
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join(" "),
_ => return None,
};
let txt = txt.trim();
if txt.is_empty() || txt.starts_with('<') {
return None;
}
Some(txt.to_string())
}
fn last_user_msg_jsonl(path: &std::path::Path) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path).ok()?;
let len = f.metadata().ok()?.len();
let start = len.saturating_sub(128 * 1024);
f.seek(SeekFrom::Start(start)).ok()?;
let mut bytes = Vec::new();
f.read_to_end(&mut bytes).ok()?;
let text = String::from_utf8_lossy(&bytes);
let mut lines: Vec<&str> = text.lines().collect();
if start > 0 && !lines.is_empty() {
lines.remove(0); }
lines.iter().rev().find_map(|l| parse_user_line(l))
}
fn first_user_msg_jsonl(path: &std::path::Path) -> Option<String> {
use std::io::Read;
let mut f = std::fs::File::open(path).ok()?;
let mut buf = vec![0u8; 96 * 1024];
let n = f.read(&mut buf).ok()?;
let text = String::from_utf8_lossy(&buf[..n]);
text.lines().find_map(parse_user_line)
}
fn last_user_msg_a3s(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
for m in v.get("messages")?.as_array()?.iter().rev() {
if m.get("role").and_then(|r| r.as_str()) != Some("user") {
continue;
}
let txt = match m.get("content") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(a)) => a
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join(" "),
_ => continue,
};
if !txt.trim().is_empty() {
return Some(txt.trim().to_string());
}
}
None
}
fn jsonl_session_name(p: &std::path::Path) -> String {
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| {
let s = s.strip_prefix("rollout-").unwrap_or(s);
s.chars().take(19).collect::<String>().replace('T', " ")
})
.unwrap_or_else(|| "session".into())
}
fn scan_relay(cwd: &str) -> Vec<RelaySession> {
let mut out: Vec<RelaySession> = Vec::new();
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
let mut p = std::path::Path::new(cwd);
loop {
dirs.push(p.to_path_buf());
match p.parent() {
Some(par) if par != p && dirs.len() < 6 => p = par,
_ => break,
}
}
for d in &dirs {
if let Ok(entries) = std::fs::read_dir(d.join(".a3s/tui-sessions")) {
for e in entries.flatten() {
let f = e.path();
if let Some(id) = f.is_file().then(|| f.file_stem()?.to_str()).flatten() {
let mtime = std::fs::metadata(&f)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let label = match last_user_msg_a3s(&f) {
Some(m) => format!("a3s-code · {}", truncate(&m, 56)),
None => format!("a3s-code · session {id}"),
};
out.push(RelaySession {
agent: "a3s-code",
native_id: Some(id.to_string()),
seed: None,
label,
mtime,
});
}
}
}
}
if let Some(home) = std::env::var_os("HOME") {
let home = std::path::PathBuf::from(home);
for d in &dirs {
let encoded = format!(
"-{}",
d.to_string_lossy()
.trim_start_matches('/')
.replace('/', "-")
);
collect_jsonl(
&home.join(".claude/projects").join(&encoded),
"claude code",
&mut out,
);
}
collect_jsonl(&home.join(".codex/sessions"), "codex", &mut out);
}
out.sort_by_key(|e| std::cmp::Reverse(e.mtime));
const PER_AGENT: usize = 8;
let mut kept: std::collections::HashMap<&'static str, usize> = std::collections::HashMap::new();
out.retain(|s| {
let n = kept.entry(s.agent).or_insert(0);
*n += 1;
*n <= PER_AGENT
});
out
}
fn gather_jsonl(
dir: &std::path::Path,
depth: usize,
max: usize,
out: &mut Vec<(std::path::PathBuf, std::time::SystemTime)>,
) {
if depth > max {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
gather_jsonl(&p, depth + 1, max, out);
} else if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
let mtime = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
out.push((p, mtime));
}
}
}
fn collect_jsonl(dir: &std::path::Path, agent: &'static str, out: &mut Vec<RelaySession>) {
let mut paths: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
gather_jsonl(dir, 0, 6, &mut paths);
paths.sort_by_key(|e| std::cmp::Reverse(e.1)); paths.truncate(12);
for (p, mtime) in paths {
let desc = last_user_msg_jsonl(&p).or_else(|| first_user_msg_jsonl(&p));
let label = match &desc {
Some(m) => format!("{agent} · {}", truncate(m, 56)),
None => format!("{agent} · {}", jsonl_session_name(&p)),
};
out.push(RelaySession {
agent,
native_id: None,
seed: desc,
label,
mtime,
});
}
}
#[derive(Clone, Copy, PartialEq)]
enum Mode {
Default,
Plan,
Auto,
}
impl Mode {
fn next(self) -> Self {
match self {
Mode::Default => Mode::Plan,
Mode::Plan => Mode::Auto,
Mode::Auto => Mode::Default,
}
}
fn glyph(self) -> &'static str {
match self {
Mode::Default => "⏵",
Mode::Plan => "✎",
Mode::Auto => "⏵⏵",
}
}
fn name(self) -> &'static str {
match self {
Mode::Default => "default",
Mode::Plan => "plan",
Mode::Auto => "auto",
}
}
fn color(self) -> Color {
match self {
Mode::Default => TN_FG,
Mode::Plan => TN_CYAN,
Mode::Auto => TN_GREEN,
}
}
fn auto_approves(self, tool: &str) -> bool {
match self {
Mode::Auto => true,
Mode::Plan => is_readonly_tool(tool),
Mode::Default => false,
}
}
}
fn is_readonly_tool(name: &str) -> bool {
matches!(
name,
"read" | "grep" | "ls" | "glob" | "find" | "search" | "web_search" | "web_fetch"
)
}
struct Queued {
prio: u8,
seq: u64,
text: String,
}
impl PartialEq for Queued {
fn eq(&self, o: &Self) -> bool {
self.prio == o.prio && self.seq == o.seq
}
}
impl Eq for Queued {}
impl Ord for Queued {
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
o.prio.cmp(&self.prio).then(o.seq.cmp(&self.seq))
}
}
impl PartialOrd for Queued {
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(o))
}
}
type SharedRx = Arc<Mutex<mpsc::Receiver<AgentEvent>>>;
type SharedManifestRx =
Arc<Mutex<tokio::sync::broadcast::Receiver<LocalWorkspaceManifestSnapshot>>>;
#[derive(PartialEq)]
enum State {
Idle,
Streaming,
Awaiting,
}
#[derive(Clone)]
#[allow(clippy::enum_variant_names)]
enum Action {
ScrollUp,
ScrollDown,
ScrollTop,
ScrollBottom,
}
static UPGRADE_ON_EXIT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static LATEST: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
enum Msg {
Term(Event),
Agent(Box<AgentEvent>),
Submit(String),
StreamStarted(SharedRx),
StreamEnded,
StreamError(String),
WorkspaceManifest(Box<LocalWorkspaceManifestSnapshot>),
WorkspaceManifestStopped,
SpinnerTick,
BannerTick,
ModalConfirm(usize),
Resume,
Interrupted,
ShellOutput(String),
UpdatePlan(Option<String>),
OsLogin(Result<String, String>),
SshKeySynced(crate::a3s_os::SshKeyOutcome),
OsRefreshed(Result<crate::a3s_os::StoredOsSession, String>),
OsGatewayModels(Result<Vec<crate::a3s_os::GatewayModel>, String>),
SideNote(String),
TopData(Vec<ProcessRow>),
TopRefresh,
ImConvs(Result<Vec<os_im::ImConversation>, String>),
ImHistory(String, Result<Vec<os_im::ImMessage>, String>),
ImSent(Result<Box<os_im::ImMessage>, String>),
ImDmOpened(Result<Box<os_im::ImConversation>, String>),
ImContacts(Result<Vec<os_im::Contact>, String>),
ImMe(String),
ImPoll,
Noop,
Forked(Result<String, String>),
RelayData(Vec<RelaySession>),
GitStatus(Vec<GitFile>, Vec<String>),
GitDiff(Vec<String>),
MemoryLoaded(Vec<MemEntry>),
KbAdded(String),
CtxResults(Result<String, String>),
CtxWindow(Result<(String, String), String>),
CtxSaved(Result<String, String>),
SleepSaved(Result<usize, String>),
FlowOpened(Result<(String, String), String>),
CtxMemorySource(Result<(String, String), String>),
AutoReview(String),
Compacted(String),
UpdateCheck(Option<String>),
}
impl From<Event> for Msg {
fn from(event: Event) -> Self {
Msg::Term(event)
}
}
fn pump(rx: SharedRx) -> Cmd<Msg> {
cmd::cmd(move || async move {
let mut guard = rx.lock().await;
match guard.recv().await {
Some(event) => Msg::Agent(Box::new(event)),
None => Msg::StreamEnded,
}
})
}
fn pump_manifest(rx: SharedManifestRx) -> Cmd<Msg> {
cmd::cmd(move || async move {
let mut guard = rx.lock().await;
loop {
match guard.recv().await {
Ok(snapshot) => return Msg::WorkspaceManifest(Box::new(snapshot)),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
return Msg::WorkspaceManifestStopped;
}
}
}
})
}
fn spinner_tick() -> Cmd<Msg> {
cmd::tick(Duration::from_millis(80), Msg::SpinnerTick)
}
fn banner_tick() -> Cmd<Msg> {
cmd::tick(Duration::from_millis(280), Msg::BannerTick)
}
fn with_recent_workspace_context(
opts: SessionOptions,
manifest: &Arc<LocalWorkspaceManifest>,
) -> SessionOptions {
opts.with_context_provider(Arc::new(RecentWorkspaceFilesContextProvider::new(
manifest.clone(),
)))
}
fn touch_workspace_file_path_for_manifest(
manifest: &LocalWorkspaceManifest,
workspace: &str,
path: &Path,
) {
let root = Path::new(workspace);
if let Ok(relative) = path.strip_prefix(root) {
if let Some(path) = relative.to_str() {
manifest.touch_file(path);
}
}
}
struct SubAgent {
task_id: String,
agent: String,
description: String,
started: Instant,
ended: Option<Instant>,
tokens: u64,
done: bool,
success: Option<bool>,
}
struct App {
session: Arc<AgentSession>,
agent: Arc<Agent>,
store: Arc<dyn a3s_code_core::store::SessionStore>,
confirmation: a3s_code_core::hitl::ConfirmationPolicy,
session_id: String,
models: Vec<String>,
model_ctx: std::collections::HashMap<String, u32>,
context_limit: u32,
last_prompt_tokens: usize,
ctx_warned_tier: u8,
model_menu: Option<usize>,
model_tab: usize,
llm_override: Option<Arc<dyn a3s_code_core::llm::LlmClient>>,
os_config: Option<OsConfig>,
os_session: Option<crate::a3s_os::StoredOsSession>,
os_refreshing: bool,
os_gateway_models: Option<Vec<String>>,
os_gateway_error: Option<String>,
last_view: Option<remote_ui::ViewSpec>,
effort: usize,
effort_panel: Option<usize>,
theme_panel: Option<usize>,
relay: Vec<RelaySession>,
relay_menu: Option<usize>,
relay_tab: usize,
quit_armed: Option<Instant>,
last_activity: Instant,
auto_reviewed: bool,
shell_mode: bool,
research_mode: bool,
review_mode: bool,
review_pending: bool,
sleep_pending: bool,
review: Option<panels::review::ReviewState>,
repo_picker: Option<panels::repos::RepoPanel>,
flow: Option<panels::flow::FlowPanel>,
evolve: Option<panels::evolve::EvolvePanel>,
evolve_mode: bool,
evolve_target: Option<(std::path::PathBuf, String)>,
review_open: bool,
ctx_ready: bool,
ctx_hits: Vec<panels::ctx::CtxHit>,
pending_ctx: Option<String>,
loop_continuation: bool,
turn_text: String,
selection: Option<Selection>,
last_workflow: Option<String>,
pending_images: Vec<a3s_code_core::llm::Attachment>,
goal: Option<String>,
goal_since: Option<Instant>,
loop_remaining: usize,
active_tools: usize,
active_agents: usize,
subagents: Vec<SubAgent>,
turn_had_agent_activity: bool,
turn_text_after_activity: bool,
ultracode_synthesis_inflight: bool,
ultracode_synthesis_used: bool,
instructions: Option<String>,
compact_summary: Option<String>,
workspace_manifest: Arc<LocalWorkspaceManifest>,
workspace_manifest_rx: SharedManifestRx,
workspace_services: Arc<WorkspaceServices>,
rainbow_until: Option<Instant>,
rainbow_frame: usize,
effort_anim: Option<Instant>,
btw: Option<(String, Option<String>)>,
viewport: Viewport,
textarea: Textarea,
spinner: Spinner,
streaming: StreamingMarkdown,
got_delta: bool,
compacting: Option<Instant>,
updating: Option<Instant>,
last_paint: Option<Instant>,
thinking: String,
state: State,
messages: Vec<String>,
rx: Option<SharedRx>,
pending_tool: Option<(String, String)>,
approval_sel: usize,
history: Vec<String>,
history_pos: Option<usize>,
model: Option<String>,
output_tokens: usize,
tool_args: String,
tool_output: String,
tool_log: Vec<ToolCallRecord>,
stream_started: Option<Instant>,
running_tool: Option<String>,
blink_tick: u8,
anim: u8,
mode: Mode,
autonomy_restore: Option<Mode>,
queue: BinaryHeap<Queued>,
seq: u64,
running_task: Option<String>,
plan: Vec<(String, String, char, Color)>, top: Option<Vec<ProcessRow>>,
top_scroll: usize,
top_sel: usize,
top_focus: Option<u32>,
top_kill: Option<(u32, String)>,
ide: Option<Ide>,
chat: Option<ChatPanel>,
git: Option<Git>,
memory: Option<MemPanel>,
help_open: bool,
completed: usize,
cwd: String,
branch: Option<String>,
slash_sel: usize,
files: Vec<String>,
file_sel: usize,
at_expanded: std::collections::HashSet<String>,
skill_count: usize,
skills: Vec<(String, String)>,
disabled_skills: std::collections::HashSet<String>,
plugins_panel: Option<usize>,
update_available: Option<String>,
width: u16,
height: u16,
keymap: Keymap<Action>,
}
impl App {
pub(crate) fn touch_workspace_file(&self, path: &str) {
self.workspace_manifest.touch_file(path);
}
}
impl Model for App {
type Msg = Msg;
fn init(&mut self) -> Option<Cmd<Msg>> {
let mut cmds = vec![cmd::cmd(|| async {
Msg::UpdateCheck(check_latest_version().await)
})];
cmds.push(pump_manifest(self.workspace_manifest_rx.clone()));
cmds.push(banner_tick());
if self.messages.is_empty() {
self.viewport.set_content(&self.banner());
} else {
self.rebuild_viewport();
self.viewport.update(ViewportMsg::Bottom);
}
Some(cmd::batch(cmds))
}
fn update(&mut self, msg: Msg) -> Option<Cmd<Msg>> {
match msg {
Msg::Term(Event::Resize { width, height }) => {
self.selection = None; self.width = width;
self.height = height;
self.relayout();
self.textarea
.set_width(width.saturating_sub((PAD + 2) as u16));
if let Some(f) = self
.ide
.as_mut()
.and_then(|i| i.file.as_mut())
.filter(|f| f.image)
{
let inner = panels::spf::ide_split(width as usize).1.saturating_sub(2);
let body = (height as usize).saturating_sub(5);
f.lines = render_image_file(&f.path, inner, body)
.unwrap_or_else(|| vec!["<cannot decode image>".into()]);
f.scroll = 0;
}
let raw = self.streaming.raw_content().to_string();
self.streaming = StreamingMarkdown::new((width as usize).saturating_sub(PAD + 2));
if !raw.is_empty() {
self.streaming.push(&raw);
}
self.rebuild_viewport();
}
Msg::Term(Event::Paste(text)) => {
self.last_activity = Instant::now();
self.textarea.insert_str(&text);
self.relayout();
}
Msg::Term(Event::Key(key)) => {
self.last_activity = Instant::now();
self.auto_reviewed = false;
self.selection = None; if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
match self.quit_armed {
Some(t) if t.elapsed() < Duration::from_secs(2) => return Some(cmd::quit()),
_ => {
self.quit_armed = Some(Instant::now());
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" press Ctrl+C again to exit"),
);
return None;
}
}
}
if self.btw.is_some() && key.code == KeyCode::Esc {
self.btw = None;
return None;
}
if self.help_open {
self.help_open = false;
return None;
}
if self.git.is_some() {
return self.git_key(&key);
}
if self.memory.is_some() {
return self.memory_key(&key);
}
if self.ide.is_some() {
if self.state == State::Awaiting {
return self.handle_approval_key(&key);
}
self.ide_key(&key);
return None;
}
if self.chat.is_some() {
return self.handle_chat_key(&key);
}
if self.top.is_some() {
if self.top_kill.is_some() {
match key.code {
KeyCode::Char('y' | 'Y') | KeyCode::Enter => {
let pid = self.top_kill.take().unwrap().0;
return Some(cmd::cmd(move || async move {
let _ = tokio::process::Command::new("kill")
.arg("-9")
.arg(pid.to_string())
.output()
.await;
Msg::TopData(fetch_top().await) }));
}
KeyCode::Char('n' | 'N') | KeyCode::Esc => self.top_kill = None,
_ => {}
}
return None;
}
let rows = self.top_rows();
let last = rows.len().saturating_sub(1);
match key.code {
KeyCode::Esc => {
if self.top_focus.is_some() {
self.top_focus = None;
self.top_sel = 0;
self.top_scroll = 0;
} else {
self.top = None;
}
}
KeyCode::Up | KeyCode::Char('k') => {
self.top_sel = self.top_sel.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.top_sel = (self.top_sel + 1).min(last)
}
KeyCode::PageUp => self.top_sel = self.top_sel.saturating_sub(10),
KeyCode::PageDown => self.top_sel = (self.top_sel + 10).min(last),
KeyCode::Enter | KeyCode::Right => {
if self.top_focus.is_none() {
if let Some(row) = rows.get(self.top_sel) {
if row.agent.is_some() {
self.top_focus = Some(row.pid);
self.top_sel = 0;
self.top_scroll = 0;
}
}
}
}
KeyCode::Char('K') => {
self.top_kill =
rows.get(self.top_sel).map(|r| (r.pid, r.command.clone()));
}
_ => {}
}
let body = (self.height as usize).saturating_sub(3);
if self.top_sel < self.top_scroll {
self.top_scroll = self.top_sel;
} else if self.top_sel >= self.top_scroll + body {
self.top_scroll = self.top_sel + 1 - body;
}
return None;
}
if key.code == KeyCode::BackTab {
self.mode = self.mode.next();
return None;
}
if self.state == State::Awaiting {
return self.handle_approval_key(&key);
}
if self.model_menu.is_some() {
return self.handle_model_key(&key).unwrap_or(None);
}
if let Some(sel) = self.effort_panel {
match key.code {
KeyCode::Left => self.effort_panel = Some(sel.saturating_sub(1)),
KeyCode::Right => {
self.effort_panel = Some((sel + 1).min(EFFORT_LEVELS.len() - 1))
}
KeyCode::Enter => {
self.effort = sel;
if sel == ULTRACODE {
self.effort_anim = Some(Instant::now());
self.rainbow_frame = 0;
} else {
self.effort_panel = None;
self.apply_effort();
}
}
KeyCode::Esc => {
self.effort_panel = None;
self.effort_anim = None;
}
_ => {}
}
return None;
}
if let Some(sel) = self.theme_panel {
match key.code {
KeyCode::Up => self.theme_panel = Some(sel.saturating_sub(1)),
KeyCode::Down => self.theme_panel = Some((sel + 1).min(THEMES.len() - 1)),
KeyCode::Enter => {
SYNTAX_THEME.store(sel, std::sync::atomic::Ordering::Relaxed);
self.theme_panel = None;
self.rebuild_viewport();
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ◆ code theme: {}", THEMES[sel].name)),
);
}
KeyCode::Esc => self.theme_panel = None,
_ => {}
}
return None;
}
if let Some(sel) = self.plugins_panel {
let last = self.skills.len().saturating_sub(1);
match key.code {
KeyCode::Up => self.plugins_panel = Some(sel.saturating_sub(1)),
KeyCode::Down => self.plugins_panel = Some((sel + 1).min(last)),
KeyCode::Char(' ') => {
if let Some((name, _)) = self.skills.get(sel.min(last)) {
let name = name.clone();
if !self.disabled_skills.remove(&name) {
self.disabled_skills.insert(name);
}
save_disabled_skills(&self.disabled_skills);
}
}
KeyCode::Esc => self.plugins_panel = None,
_ => {}
}
return None;
}
if self.relay_menu.is_some() {
return self.handle_relay_key(&key).unwrap_or(None);
}
if self.review_open {
return self.handle_review_key(&key);
}
if self.repo_picker.is_some() {
return self.handle_repo_picker_key(&key);
}
if self.flow.is_some() {
return self.handle_flow_key(&key);
}
if self.evolve.is_some() {
return self.handle_evolve_key(&key);
}
if key.code == KeyCode::End && key.modifiers.contains(KeyModifiers::SHIFT) {
self.viewport.update(ViewportMsg::Bottom);
self.viewport.set_auto_scroll(true);
return None;
}
if let Some(action) = self.keymap.resolve(&key) {
let m = match action {
Action::ScrollUp => ViewportMsg::PageUp,
Action::ScrollDown => ViewportMsg::PageDown,
Action::ScrollTop => ViewportMsg::Top,
Action::ScrollBottom => ViewportMsg::Bottom,
};
self.viewport.update(m);
self.viewport.set_auto_scroll(self.viewport.at_bottom());
return None;
}
if (self.shell_mode || self.research_mode || self.review_mode || self.evolve_mode)
&& key.code == KeyCode::Esc
{
self.shell_mode = false;
self.research_mode = false;
self.review_mode = false;
self.evolve_mode = false;
self.evolve_target = None;
self.textarea.clear();
return None;
}
if self.state == State::Streaming && key.code == KeyCode::Esc {
self.push_line(&Style::new().fg(TN_YELLOW).render(" ⎋ interrupting…"));
let session = self.session.clone();
return Some(cmd::cmd(move || async move {
session.cancel().await;
Msg::Interrupted
}));
}
if self.slash_menu_open() {
if let Some(result) = self.handle_slash_key(&key) {
return result;
}
}
if self.file_menu_open() {
if let Some(result) = self.handle_file_key(&key) {
return result;
}
}
if matches!(key.code, KeyCode::Up | KeyCode::Down)
&& !self.textarea.value().contains('\n')
&& !self.history.is_empty()
{
self.history_recall(key.code == KeyCode::Up);
return None;
}
if key.code == KeyCode::Char('v') && key.modifiers.contains(KeyModifiers::CONTROL) {
self.paste_clipboard_image();
return None;
}
if let Some(TextareaMsg::Submit(text)) = self.textarea.handle_key(&key) {
return Some(cmd::msg(Msg::Submit(text)));
}
let val = self.textarea.value();
if !self.shell_mode && !self.research_mode && !self.review_mode {
if let Some(rest) = val.strip_prefix('!') {
self.shell_mode = true;
self.textarea.set_value(rest);
} else if let Some(rest) = val.strip_prefix('?') {
self.research_mode = true;
self.textarea.set_value(rest);
} else if let Some(rest) = val.strip_prefix('&') {
self.review_mode = true;
self.textarea.set_value(rest);
}
}
}
Msg::Term(Event::Mouse(m)) => {
use a3s_tui::event::{MouseButton, MouseEventKind};
if self.ide.is_some() {
return None;
}
let vp_rows = self.viewport_rows();
let max_col = (self.width as usize).saturating_sub(2) as u16;
match m.kind {
MouseEventKind::ScrollUp => {
self.selection = None;
self.viewport.update(ViewportMsg::ScrollUp(3));
}
MouseEventKind::ScrollDown => {
self.selection = None;
self.viewport.update(ViewportMsg::ScrollDown(3));
}
MouseEventKind::Down(MouseButton::Left) => {
self.selection = if (m.row as usize) < vp_rows {
let p = (m.row, m.column.min(max_col));
Some(Selection { anchor: p, head: p })
} else {
None
};
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(s) = self.selection.as_mut() {
let row = m.row.min(vp_rows.saturating_sub(1) as u16);
s.head = (row, m.column.min(max_col));
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(s) = self.selection {
if s.is_empty() {
let view = self.viewport.view();
let clicked = a3s_tui::style::strip_ansi(
view.split('\n')
.nth(s.anchor.0 as usize)
.unwrap_or_default(),
);
self.selection = None;
if clicked.contains(VIEW_BUTTON_MARKER) {
if let Some(spec) = self.last_view.clone() {
self.open_remote_view(&spec);
}
}
} else {
let (r1, c1, r2, c2) = s.ordered();
let text = selection_to_text(&self.viewport.view(), r1, c1, r2, c2);
if text.trim().is_empty() {
self.selection = None;
} else {
copy_to_clipboard(&text);
}
}
}
}
_ => {}
}
self.viewport.set_auto_scroll(self.viewport.at_bottom());
}
Msg::Submit(text) => return self.on_submit(text),
Msg::StreamStarted(rx) => {
self.rx = Some(rx.clone());
return Some(pump(rx));
}
Msg::StreamError(e) => {
self.push_line(&Style::new().fg(TN_RED).render(&format!(" error: {e}")));
self.loop_remaining = 0; self.review_pending = false; self.sleep_pending = false; self.restore_autonomy();
self.finish();
return self.drain_queue();
}
Msg::WorkspaceManifest(snapshot) => {
self.files = snapshot.file_paths();
self.file_sel = self.file_sel.min(self.files.len().saturating_sub(1));
return Some(pump_manifest(self.workspace_manifest_rx.clone()));
}
Msg::WorkspaceManifestStopped => {
let snapshot = self.workspace_manifest.snapshot();
self.files = snapshot.file_paths();
self.file_sel = self.file_sel.min(self.files.len().saturating_sub(1));
}
Msg::Interrupted => {
self.finalize_streaming();
self.push_line(&Style::new().fg(TN_YELLOW).render(" ⎋ interrupted"));
self.loop_remaining = 0; self.review_pending = false; self.sleep_pending = false; self.restore_autonomy();
self.finish();
return self.drain_queue();
}
Msg::Agent(event) => return self.on_agent_event(*event),
Msg::StreamEnded => {
if self.state == State::Streaming {
self.finalize_streaming();
}
let turn_text = self.turn_text.clone();
self.capture_review(&turn_text);
let sleep_save = self.capture_sleep(&turn_text);
self.disarm_sleep_if_over(sleep_save.is_some());
return match (sleep_save, self.complete_turn()) {
(Some(save), Some(next)) => Some(cmd::batch(vec![save, next])),
(save, next) => save.or(next),
};
}
Msg::SpinnerTick => {
self.spinner.tick();
self.blink_tick = self.blink_tick.wrapping_add(1);
if self.state == State::Streaming {
self.update_viewport_with_stream();
return Some(spinner_tick());
}
}
Msg::BannerTick => {
if self.messages.is_empty()
&& self.state == State::Idle
&& self.top.is_none()
&& self.ide.is_none()
&& self.git.is_none()
&& self.memory.is_none()
&& !self.help_open
{
self.anim = self.anim.wrapping_add(1);
self.viewport.set_content(&self.banner());
}
if self.rainbow_until.is_some() || self.effort_anim.is_some() {
self.rainbow_frame = self.rainbow_frame.wrapping_add(1);
}
if let Some(t) = self.effort_anim {
if t.elapsed() > Duration::from_millis(1100) {
self.effort_anim = None;
self.effort_panel = None;
self.apply_effort();
}
}
if !self.auto_reviewed
&& self.state == State::Idle
&& !self.messages.is_empty()
&& self.last_activity.elapsed() > Duration::from_secs(300)
{
self.auto_reviewed = true;
let agent = self.agent.clone();
let workspace = self.cwd.clone();
let history = self.session.history();
let review = cmd::cmd(move || async move {
let conf = a3s_code_core::hitl::ConfirmationPolicy::enabled()
.with_timeout(500, TimeoutAction::Reject);
let prompt = "Briefly review this conversation so far: summarise the \
key decisions and what's done, then list any open threads or next \
steps. Keep it to a few lines.";
let mut answer = String::new();
if let Ok(sess) = agent.session(
workspace,
Some(SessionOptions::new().with_confirmation_policy(conf)),
) {
if let Ok((mut rx, _j)) = sess.stream(prompt, Some(&history)).await {
while let Some(ev) = rx.recv().await {
match ev {
AgentEvent::TextDelta { text } => answer.push_str(&text),
AgentEvent::End { text, .. } => {
if answer.trim().is_empty() {
answer = text;
}
break;
}
_ => {}
}
}
}
}
Msg::AutoReview(answer)
});
return Some(cmd::batch(vec![banner_tick(), review]));
}
if !self.os_refreshing {
if let Some(s) = &self.os_session {
if crate::a3s_os::needs_refresh(s) {
self.os_refreshing = true;
let session = s.clone();
let refresh = cmd::cmd(move || async move {
Msg::OsRefreshed(
crate::a3s_os::refresh_session(&session)
.await
.map_err(|e| e.to_string()),
)
});
return Some(cmd::batch(vec![banner_tick(), refresh]));
}
}
}
return Some(banner_tick());
}
Msg::AutoReview(text) => {
if !text.trim().is_empty() {
let dim =
|s: &str| format!(" {}", Style::new().fg(TN_GRAY).italic().render(s));
let mut lines = vec![dim("⟳ inactivity review")];
lines.extend(text.trim().lines().map(dim));
self.push_line(&lines.join("\n"));
}
}
Msg::Compacted(summary) => {
self.compacting = None;
if summary.trim().is_empty() {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(" compaction failed (empty summary)"),
);
return None;
}
self.compact_summary = Some(summary.trim().to_string());
self.session_id = new_session_id();
let model = self.model.clone();
match self.rebuild_session(model.as_deref()) {
Ok((s, _)) => {
self.session = Arc::new(s);
self.messages.clear();
self.output_tokens = 0;
self.last_prompt_tokens = 0;
self.ctx_warned_tier = 0; self.push_line(
&Style::new()
.fg(TN_GREEN)
.bold()
.render(" ✦ context compacted — continuing from this summary:"),
);
self.push_line(&gutter(
TN_CYAN,
self.compact_summary.as_deref().unwrap_or(""),
));
self.rebuild_viewport();
}
Err(e) => self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" compaction failed: {e}")),
),
}
}
Msg::UpdateCheck(latest) => {
let newer = latest
.as_deref()
.is_some_and(|l| !crate::update::version_ge(env!("CARGO_PKG_VERSION"), l));
if newer {
self.update_available = latest;
if self.messages.is_empty() {
self.viewport.set_content(&self.banner());
}
}
}
Msg::ModalConfirm(idx) => {
let approved = idx == 0;
self.state = State::Streaming;
if let Some((tool_id, label)) = self.pending_tool.take() {
if !approved {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" ⎿ denied {label}")),
);
}
let session = self.session.clone();
return Some(cmd::batch(vec![
cmd::cmd(move || async move {
let _ = session.confirm_tool_use(&tool_id, approved, None).await;
Msg::Resume
}),
spinner_tick(),
]));
}
}
Msg::Resume => {
if let Some(rx) = self.rx.clone() {
return Some(pump(rx));
}
}
Msg::ShellOutput(text) => {
let body = text.lines().take(40).collect::<Vec<_>>().join("\n");
self.push_line(&gutter(TN_GRAY, body.trim_end()));
}
Msg::UpdatePlan(latest) => {
self.updating = None;
self.relayout();
let current = env!("CARGO_PKG_VERSION");
match latest {
None => self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" couldn't reach the release server — try again later"),
),
Some(l) if crate::update::version_ge(current, &l) => self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(&format!(" ✓ already up to date (a3s {current})")),
),
Some(l) => {
if crate::update::can_self_update() {
if let Ok(mut g) = LATEST.lock() {
*g = Some(l.clone());
}
UPGRADE_ON_EXIT.store(true, std::sync::atomic::Ordering::Relaxed);
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(
" → a3s {l} available — closing to upgrade, then restarting…"
)));
return Some(cmd::quit());
}
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" → a3s {l} available — download: https://github.com/A3S-Lab/Cli/releases/latest"
)));
}
}
}
Msg::OsLogin(result) => match result {
Ok(label) => {
self.os_session = self
.os_config
.as_ref()
.and_then(crate::a3s_os::current_session);
if let Some(s) = &self.os_session {
crate::a3s_os::export_os_env(s);
}
self.refresh_after_auth();
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(
" ✓ signed in to OS as {label} · capabilities skill active"
)));
if let Some(s) = self.os_session.clone() {
return Some(cmd::cmd(move || async move {
Msg::SshKeySynced(crate::a3s_os::sync_ssh_key(s).await)
}));
}
}
Err(error) => self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" login failed: {error}")),
),
},
Msg::SshKeySynced(outcome) => {
use crate::a3s_os::SshKeyOutcome;
match outcome {
SshKeyOutcome::Registered(fp) => self.push_line(&Style::new().fg(TN_GREEN).render(
&format!(" ✓ 本机 SSH 公钥已登记到 OS({fp})· git clone(ssh) 就绪"),
)),
SshKeyOutcome::AlreadyRegistered => self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(" · SSH 公钥已在 OS,跳过登记"),
),
SshKeyOutcome::NoLocalKey => self.push_line(&Style::new().fg(TN_YELLOW).render(
" · 未找到本机 SSH 公钥;生成后重新 /login 即可自动登记:ssh-keygen -t ed25519",
)),
SshKeyOutcome::Failed(e) => self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(&format!(" · SSH key 同步跳过:{e}")),
),
}
}
Msg::OsRefreshed(result) => {
self.os_refreshing = false;
match result {
Ok(session) => {
crate::a3s_os::export_os_env(&session);
self.os_session = Some(session);
}
Err(_) => {
}
}
}
Msg::OsGatewayModels(result) => {
match result {
Ok(models) => {
for m in &models {
if let Some(ctx) = m.context {
self.model_ctx.insert(m.id.clone(), ctx);
}
}
self.os_gateway_models = Some(models.into_iter().map(|m| m.id).collect());
self.os_gateway_error = None;
}
Err(e) => {
self.os_gateway_models = Some(Vec::new());
self.os_gateway_error = Some(e);
}
}
self.open_model_menu();
}
Msg::SideNote(text) => {
if let Some((q, _)) = self.btw.take() {
self.btw = Some((q, Some(text.trim().to_string())));
}
}
Msg::TopData(rows) => {
if self.top.is_some() {
self.top = Some(rows);
return Some(cmd::tick(Duration::from_millis(1500), Msg::TopRefresh));
}
}
Msg::TopRefresh => {
if self.top.is_some() {
return Some(cmd::cmd(|| async { Msg::TopData(fetch_top().await) }));
}
}
Msg::ImConvs(result) => return self.on_im_convs(result),
Msg::ImHistory(id, result) => return self.on_im_history(id, result),
Msg::ImSent(result) => {
if let Some(chat) = &mut self.chat {
match result {
Ok(msg) => {
chat.error = None;
if chat.active.as_deref() == Some(msg.conversation_id.as_str()) {
chat.messages.push(*msg);
}
}
Err(e) => chat.error = Some(e),
}
}
}
Msg::ImPoll => {
if self.chat.is_some() {
if self.os_session.is_none() {
self.chat = None;
return None;
}
return Some(self.chat_refresh());
}
}
Msg::ImDmOpened(result) => return self.on_im_dm_opened(result),
Msg::ImMe(id) => {
if let Some(chat) = &mut self.chat {
if !id.is_empty() {
chat.me = Some(id);
}
}
}
Msg::ImContacts(result) => {
if let Some(chat) = &mut self.chat {
match result {
Ok(contacts) => {
chat.contacts = contacts;
chat.contact_sel = 0;
chat.error = None;
}
Err(e) => chat.error = Some(e),
}
}
}
Msg::Noop => {}
Msg::Forked(result) => {
match result {
Ok(new_id) => {
let prev = std::mem::replace(&mut self.session_id, new_id);
let model = self.model.clone();
match self.rebuild_session(model.as_deref()) {
Ok((s, _)) => {
self.session = Arc::new(s);
let short: String = self.session_id.chars().take(8).collect();
self.push_line(&gutter(
TN_CYAN,
&format!("⑂ forked into a new session ({short}) — the original is kept"),
));
}
Err(e) => {
self.session_id = prev;
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" fork failed: {e}")),
);
}
}
}
Err(e) => {
self.push_line(&Style::new().fg(TN_YELLOW).render(&format!(" /fork: {e}")))
}
}
}
Msg::RelayData(sessions) => {
if self.relay_menu.is_some() {
self.relay = sessions;
}
}
Msg::GitStatus(files, log) => {
if let Some(g) = &mut self.git {
g.files = files;
g.log = log;
g.sel = g.sel.min(g.files.len().saturating_sub(1));
g.log_sel = g.log_sel.min(g.log.len().saturating_sub(1));
g.note.clear();
return self.git_load_diff();
}
}
Msg::GitDiff(lines) => {
if let Some(g) = &mut self.git {
g.diff = lines;
g.diff_scroll = 0;
}
}
Msg::MemoryLoaded(entries) => {
if let Some(m) = &mut self.memory {
m.note = format!("{} entries", entries.len());
m.entries = entries;
m.sel = 0;
m.refresh_detail();
}
}
Msg::KbAdded(summary) => {
let color = if summary.starts_with('✗') {
TN_RED
} else {
TN_GRAY
};
self.push_line(&Style::new().fg(color).render(&format!(" {summary}")));
}
Msg::CtxResults(res) => self.on_ctx_results(res),
Msg::CtxWindow(res) => self.on_ctx_window(res),
Msg::CtxSaved(res) => self.on_ctx_saved(res),
Msg::SleepSaved(res) => self.on_sleep_saved(res),
Msg::FlowOpened(res) => self.on_flow_opened(res),
Msg::CtxMemorySource(res) => match res {
Ok((event_id, window)) => {
self.memory = None; self.open_readonly_in_ide(&format!("ctx-source-{event_id}.txt"), &window);
}
Err(e) => {
if let Some(m) = self.memory.as_mut() {
m.note = format!("ctx source unavailable: {e}");
}
}
},
_ => {}
}
None
}
fn view(&self) -> String {
if self.help_open {
return self.render_help();
}
if let Some(g) = &self.git {
return self.render_git(g);
}
if let Some(m) = &self.memory {
return self.render_memory(m);
}
if let Some(ide) = &self.ide {
let page = self.render_ide(ide);
return self.overlay_approval(page);
}
if let Some(chat) = &self.chat {
return self.render_chat(chat);
}
if self.top.is_some() {
return self.render_top_panel();
}
let width = self.width as usize;
let raw_view = self.viewport.view();
let shown = match &self.selection {
Some(s) if !s.is_empty() => {
let (r1, c1, r2, c2) = s.ordered();
highlight_selection(&raw_view, r1, c1, r2, c2)
}
_ => raw_view,
};
let viewport_view = append_scrollbar(
&shown,
width.saturating_sub(1),
self.viewport.total_lines(),
self.viewport.scroll_percent(),
);
let inp = self.textarea.value();
let (sym, icolor, border): (&str, Color, Color) = if self.shell_mode {
("!", Color::Rgb(255, 105, 180), Color::Rgb(255, 105, 180))
} else if self.research_mode {
("?", TN_CYAN, TN_CYAN)
} else if self.review_mode {
("&", TN_PURPLE, TN_PURPLE)
} else if self.evolve_mode {
("⟲", TN_GREEN, TN_GREEN)
} else if inp.starts_with("/btw") {
("❯", TN_YELLOW, TN_YELLOW)
} else {
("❯", ACCENT, TN_GRAY)
};
let bar = width.saturating_sub(2 * PAD);
let rainbow = self
.rainbow_until
.is_some_and(|t| t.elapsed() < Duration::from_millis(1600));
const PALETTE: [Color; 7] = [
Color::Rgb(255, 0, 0),
Color::Rgb(255, 127, 0),
Color::Rgb(255, 255, 0),
Color::Rgb(0, 220, 0),
Color::Rgb(0, 150, 255),
Color::Rgb(75, 0, 200),
Color::Rgb(160, 0, 230),
];
let ribbon = |offset: usize| {
let mut s = " ".repeat(PAD);
for i in 0..bar {
let c = PALETTE[(i + self.rainbow_frame + offset) % PALETTE.len()];
s.push_str(&Style::new().fg(c).bold().render("━"));
}
s
};
let separator = if rainbow {
ribbon(3)
} else {
Style::new()
.fg(border)
.render(&format!("{}{}", " ".repeat(PAD), "─".repeat(bar)))
};
let top_separator = if rainbow {
ribbon(0)
} else {
let elabel = format!("◇ {}", EFFORT_LEVELS[self.effort].label);
let ctxlabel = if self.context_limit > 0 {
let pct = (self.last_prompt_tokens * 100 / self.context_limit as usize).min(100);
format!("{pct}% context used ")
} else {
String::new()
};
let left = bar.saturating_sub(elabel.chars().count() + ctxlabel.chars().count() + 4);
format!(
"{}{} {}{} {}",
" ".repeat(PAD),
Style::new().fg(border).render(&"─".repeat(left)),
Style::new().fg(TN_GRAY).render(&ctxlabel),
Style::new().fg(ACCENT).bold().render(&elabel),
Style::new().fg(border).render("──"),
)
};
let activity = if self.updating.is_some() {
Style::new()
.fg(TN_GREEN)
.render(" ⬇ checking for updates…")
} else if let Some(t0) = self.compacting {
let secs = t0.elapsed().as_secs();
let pct = ((secs as f64 / 30.0) * 100.0).min(95.0) as usize;
let filled = pct * 24 / 100;
let bar = format!("{}{}", "▰".repeat(filled), "▱".repeat(24 - filled));
Style::new().fg(ACCENT).render(&format!(
" ✦ Compacting context… ({}) {bar} {pct}%",
fmt_elapsed(t0.elapsed())
))
} else {
match self.state {
State::Streaming => {
let g = ['✶', '✸', '✹', '✺', '✹', '✷'][(self.blink_tick as usize / 2) % 6];
let spark = Style::new().fg(ACCENT).render(&g.to_string());
let working = shimmer("Working…", self.blink_tick as usize);
let mut tail = String::new();
if let Some(t0) = self.stream_started {
let est = self.output_tokens
+ estimate_tokens(self.streaming.raw_content())
+ estimate_tokens(&self.thinking);
tail.push_str(&format!(" ({}", fmt_elapsed(t0.elapsed())));
if est > 0 {
tail.push_str(&format!(" · ↓ {} tokens", humanize(est)));
}
tail.push(')');
}
let tail = Style::new().fg(ACCENT).render(&tail);
format!(" {spark} {working}{tail}")
}
State::Awaiting => String::new(),
State::Idle => String::new(),
}
};
let prompt = Style::new().fg(icolor).bold().render(&format!("{sym} "));
let typed = self.textarea.view();
let typed = if sym == "!" || sym == "?" || inp.starts_with("/btw") {
Style::new().fg(icolor).render(&typed)
} else {
typed
};
let input_view = {
let cont = " ".repeat(PAD + 2);
let mut parts = typed.split('\n');
let first = parts.next().unwrap_or("");
let mut s = format!("{}{}{}", " ".repeat(PAD), prompt, first);
for line in parts {
s.push('\n');
s.push_str(&cont);
s.push_str(line);
}
s
};
let dim = |s: &str| Style::new().fg(TN_GRAY).render(s);
let dir = self.cwd.rsplit('/').next().unwrap_or(&self.cwd);
let mut line1 = format!(" {}", Style::new().fg(ACCENT).bold().render(dir));
if let Some(b) = &self.branch {
line1.push_str(&format!(
" {}{}{}",
dim("git:("),
Style::new().fg(TN_YELLOW).render(b),
dim(")")
));
}
if let Some(m) = &self.model {
let name = m.rsplit('/').next().unwrap_or(m);
line1.push_str(&format!(" {}", Style::new().fg(TN_FG).render(name)));
if self.context_limit > 0 {
let win = if self.context_limit >= 1_000_000 {
format!("{}M", self.context_limit / 1_000_000)
} else {
format!("{}k", self.context_limit / 1000)
};
line1.push_str(&format!(" {}", dim(&format!("({win} context)"))));
}
}
if self.context_limit > 0 {
let pct = (self.last_prompt_tokens * 100 / self.context_limit as usize).min(100);
let c = if pct >= 85 {
TN_RED
} else if pct >= 70 {
TN_YELLOW
} else {
TN_GRAY
};
line1.push_str(&format!(
" {}",
Style::new().fg(c).render(&format!("ctx:{pct}%"))
));
} else if self.output_tokens > 0 {
line1.push_str(&format!(" {}", dim(&format!("{} tok", self.output_tokens))));
}
if self.goal.is_some() {
let elapsed = self
.goal_since
.map(|t| format!(" ({})", fmt_elapsed(t.elapsed())))
.unwrap_or_default();
line1.push_str(&format!(
" {}",
Style::new()
.fg(TN_CYAN)
.render(&format!("🎯 Pursuing goal{elapsed}"))
));
}
if self.loop_remaining > 0 {
line1.push_str(&format!(" ↻{}", self.loop_remaining));
}
if self.active_agents > 0 {
line1.push_str(&format!(" ⇉ {} agents", self.active_agents));
}
if self.active_tools > 0 {
line1.push_str(&format!(" ⚙ {} running", self.active_tools));
}
if let Some(v) = &self.update_available {
line1.push_str(&format!(" ⬆ {v}"));
}
let status1 = pad_to(&line1, width);
let mode_part = Style::new().fg(self.mode.color()).bold().render(&format!(
" {} {} mode on",
self.mode.glyph(),
self.mode.name()
));
let hints = dim(" (shift+tab to cycle) · /help · ↑↓ history · esc");
let status2 = pad_to(&format!("{mode_part}{hints}"), width);
let spacer = if self.viewport.at_bottom() {
String::new()
} else {
let label = " ↓ more below · Shift+End to jump to latest ";
let pad = width.saturating_sub(a3s_tui::style::visible_len(label)) / 2;
format!(
"{}{}",
" ".repeat(pad),
Style::new().fg(Color::Black).bg(ACCENT).render(label)
)
};
let tasks = self.task_lines();
let task_block = tasks.join("\n");
let plan = self.plan_lines();
let plan_block = plan.join("\n");
let subs = self.subagent_lines();
let sub_block = subs.join("\n");
let composed = Layout::vertical()
.item(&viewport_view, Constraint::Fill)
.item(&spacer, Constraint::Fixed(1))
.item(&activity, Constraint::Fixed(1))
.item(&plan_block, Constraint::Fixed(plan.len() as u16))
.item(&top_separator, Constraint::Fixed(1))
.item(&input_view, Constraint::Fixed(self.input_height()))
.item(&separator, Constraint::Fixed(1))
.item(&status1, Constraint::Fixed(1))
.item(&status2, Constraint::Fixed(1))
.item(&sub_block, Constraint::Fixed(subs.len() as u16))
.item(&task_block, Constraint::Fixed(tasks.len() as u16))
.render(self.height);
let composed = self.overlay_slash_menu(composed);
let composed = self.overlay_file_menu(composed);
let composed = self.overlay_model_menu(composed);
let composed = self.overlay_relay_menu(composed);
let composed = self.overlay_review_menu(composed);
let composed = self.overlay_repo_picker(composed);
let composed = self.overlay_flow_menu(composed);
let composed = self.overlay_evolve_menu(composed);
let composed = self.overlay_effort(composed);
let composed = self.overlay_theme(composed);
let composed = self.overlay_plugins(composed);
let composed = self.overlay_approval(composed);
self.overlay_btw(composed)
}
fn cursor(&self) -> Option<(u16, u16)> {
if let Some(ide) = &self.ide {
if ide.focus_editor {
if let Some(f) = &ide.file {
let width = self.width as usize;
let (tw, _) = panels::spf::ide_split(width);
let gutter = if panels::spf::ide_gutter_on(width) {
5
} else {
0
};
let x = tw + 1 + gutter + f.display_col().saturating_sub(f.hscroll);
let col = x.min(width.saturating_sub(2)) as u16;
let row = (1 + f.row.saturating_sub(f.scroll)) as u16;
return Some((col, row));
}
}
return None;
}
if self.state == State::Awaiting
|| self.top.is_some()
|| self.git.is_some()
|| self.memory.is_some()
|| self.help_open
{
return None;
}
let below = 3 + self.subagent_lines().len() as u16 + self.task_lines().len() as u16;
let row = self.height.saturating_sub(below + self.input_height())
+ self.textarea.cursor_row() as u16;
let col = (PAD + 2) as u16 + self.textarea.cursor_display_col() as u16; Some((col, row))
}
}
impl App {
fn on_submit(&mut self, text: String) -> Option<Cmd<Msg>> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if self.compacting.is_some() || self.updating.is_some() {
self.textarea.clear();
return None;
}
if self.evolve_mode {
let direction = trimmed.to_string();
self.history.push(trimmed.to_string());
self.history_pos = None;
self.textarea.clear();
let cmd = self.submit_evolve(&direction);
self.relayout();
return cmd;
}
if self.shell_mode {
self.shell_mode = false;
let cmd = trimmed.trim_start_matches('!').trim().to_string();
if cmd.is_empty() {
return None;
}
self.messages.push(gutter(
Color::Rgb(255, 105, 180),
&Style::new().bold().render(&format!("! {cmd}")),
));
self.textarea.clear();
self.rebuild_viewport();
return Some(cmd::cmd(move || async move {
let out = tokio::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.output()
.await;
let text = match out {
Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&o.stderr));
if s.trim().is_empty() {
format!("(exit {})", o.status.code().unwrap_or(-1))
} else {
s
}
}
Err(e) => format!("failed to run: {e}"),
};
Msg::ShellOutput(text)
}));
}
if self.research_mode {
self.research_mode = false;
let query = trimmed.trim_start_matches('?').trim().to_string();
if query.is_empty() {
self.textarea.clear();
return None;
}
self.history.push(trimmed.to_string());
self.history_pos = None;
self.textarea.clear();
self.goal = Some(deep_research_goal(&query));
self.goal_since = Some(Instant::now());
self.messages.push(gutter(
TN_CYAN,
&Style::new()
.bold()
.render(&format!("🔬 deep research: {query}")),
));
self.push_line(&Style::new().fg(TN_GRAY).render(
" 🎯 goal set · ↻ auto-continues until done (Esc stops · /goal clear drops it)",
));
let prompt = deep_research_prompt(&query);
let display = format!("🔬 {query}");
self.engage_autonomy(8);
if self.state == State::Idle {
return self.start_stream_inner(prompt, display, true, true, false);
}
self.seq += 1;
self.queue.push(Queued {
prio: 1,
seq: self.seq,
text: prompt,
});
self.push_line(&Style::new().fg(TN_GRAY).render(" ⋯ queued"));
self.relayout();
return None;
}
if self.review_mode {
if self.state != State::Idle {
self.push_line(&Style::new().fg(TN_YELLOW).render(
" a code review can't start while a turn is running — press Esc to stop first",
));
return None;
}
self.review_mode = false;
let url = trimmed.trim_start_matches('&').trim().to_string();
if url.is_empty() {
self.textarea.clear();
return None;
}
self.history.push(trimmed.to_string());
self.history_pos = None;
self.textarea.clear();
self.review_pending = true;
self.messages.push(gutter(
TN_PURPLE,
&Style::new()
.bold()
.render(&format!("🔎 code review: {url}")),
));
let clone_dir = repo_dir();
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" clone into {} → deep inspection → issue checklist to pick fixes (no auto-fix · Esc stops)",
clone_dir.display()
)));
let prompt = panels::review::code_review_prompt(&url, &clone_dir.to_string_lossy());
let display = format!("🔎 {url}");
self.engage_autonomy(8);
return self.start_stream_inner(prompt, display, true, true, false);
}
if self.state != State::Idle {
let cmd0 = trimmed.split_whitespace().next().unwrap_or("");
if IDLE_ONLY.contains(&cmd0) {
self.textarea.clear();
self.push_line(&Style::new().fg(TN_YELLOW).render(&format!(
" {cmd0} is unavailable while a turn is running — press Esc to stop first"
)));
return None;
}
}
if let Some(rest) = trimmed.strip_prefix("/login") {
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
} else {
self.textarea.clear();
let Some(os_config) = self.os_config.clone() else {
self.push_line(&format!(
"{}\n{}\n{}\n{}",
Style::new()
.fg(TN_YELLOW)
.render(" /login needs an OS endpoint, but none is configured."),
Style::new().fg(TN_GRAY).render(
" Add it to ~/.a3s/config.acl (or your project's .a3s/config.acl):"
),
Style::new()
.fg(TN_CYAN)
.render(" os = \"https://your-os-host.example.com\""),
Style::new()
.fg(TN_GRAY)
.render(" then restart a3s code and run /login again."),
));
return None;
};
let token = rest.trim();
if !token.is_empty() {
match crate::a3s_os::login_with_token(&os_config, token) {
Ok(session) => {
let label = session.display_label();
crate::a3s_os::export_os_env(&session);
self.os_session = Some(session);
self.refresh_after_auth();
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(
" ✓ signed in to OS as {label} · capabilities skill active"
)));
}
Err(error) => self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" login failed: {error}")),
),
}
return None;
}
if let Some(s) = &self.os_session {
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" already signed in to OS as {} · /logout to switch accounts",
s.display_label()
)));
return None;
}
self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(" opening OS login in your browser…"),
);
return Some(cmd::cmd(move || async move {
let result = crate::a3s_os::login_via_browser(os_config)
.await
.map(|session| session.display_label())
.map_err(|error| error.to_string());
Msg::OsLogin(result)
}));
}
}
if trimmed == "/logout" {
self.textarea.clear();
let Some(os_config) = self.os_config.clone() else {
self.push_line(&Style::new().fg(TN_YELLOW).render(
" configure `os = \"https://...\"` in .a3s/config.acl to enable /logout",
));
return None;
};
match crate::a3s_os::logout(&os_config) {
Ok(true) => {
self.os_session = None;
self.chat = None; crate::a3s_os::remove_capability_skill_dir();
crate::a3s_os::clear_os_env();
self.refresh_after_auth();
self.push_line(
&Style::new()
.fg(TN_GREEN)
.render(" ✓ signed out from OS · capabilities skill removed"),
);
}
Ok(false) => {
self.os_session = None;
self.chat = None; crate::a3s_os::remove_capability_skill_dir();
crate::a3s_os::clear_os_env();
self.refresh_after_auth();
self.push_line(&Style::new().fg(TN_GRAY).render(" no OS login was stored"));
}
Err(error) => self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" logout failed: {error}")),
),
}
return None;
}
if let Some(rest) = trimmed.strip_prefix("/ctx") {
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
return self.handle_ctx_command(rest);
}
}
if let Some(rest) = trimmed.strip_prefix("/kb") {
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
let arg = rest.trim().to_string();
self.textarea.clear();
if arg.is_empty() {
let root = kbutil::kb_dir(&self.cwd);
if !root.is_dir() {
self.push_line(&Style::new().fg(TN_GRAY).render(
" KB is empty — /kb <text> | /kb <file> | /kb <folder> adds to it",
));
return None;
}
let mut ide = Ide::browse(ide_children(&root, 0), "knowledge base");
ide.kb_root = Some(root);
self.ide = Some(ide);
return None;
}
let cwd = self.cwd.clone();
let now = chrono::Utc::now().to_rfc3339();
return Some(cmd::cmd(move || async move {
let summary =
tokio::task::spawn_blocking(move || kbutil::add_to_kb(&cwd, &arg, &now))
.await
.unwrap_or_else(|e| format!("✗ /kb failed: {e}"));
Msg::KbAdded(summary)
}));
}
}
if let Some(rest) = trimmed.strip_prefix("/im") {
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
self.textarea.clear();
let arg = rest.trim().to_string();
let dm_with = (!arg.is_empty()).then_some(arg);
return self.open_chat(dm_with);
}
}
if let Some(rest) = trimmed.strip_prefix("/btw") {
let q = rest.trim().to_string();
self.textarea.clear();
if q.is_empty() {
self.push_line(&Style::new().fg(TN_GRAY).render(" usage: /btw <question>"));
return None;
}
self.btw = Some((q.clone(), None));
let agent = self.agent.clone();
let workspace = self.cwd.clone();
let history = self.session.history();
return Some(cmd::cmd(move || async move {
let conf = a3s_code_core::hitl::ConfirmationPolicy::enabled()
.with_timeout(500, TimeoutAction::Reject);
let sess = match agent.session(
workspace,
Some(SessionOptions::new().with_confirmation_policy(conf)),
) {
Ok(s) => s,
Err(e) => return Msg::SideNote(format!("(/btw failed: {e})")),
};
let mut answer = String::new();
if let Ok((mut rx, _join)) = sess.stream(&q, Some(&history)).await {
while let Some(ev) = rx.recv().await {
match ev {
AgentEvent::TextDelta { text } => answer.push_str(&text),
AgentEvent::End { text, .. } => {
if answer.trim().is_empty() {
answer = text;
}
break;
}
_ => {}
}
}
}
Msg::SideNote(answer)
}));
}
if let Some(rest) = trimmed.strip_prefix("/goal") {
let g = rest.trim();
self.textarea.clear();
if g.is_empty() {
match &self.goal {
Some(cur) => self.push_line(&gutter(
TN_CYAN,
&format!("🎯 goal: {cur} (/goal clear to remove)"),
)),
None => self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(" usage: /goal <what you're working toward>"),
),
}
} else if g == "clear" {
self.goal = None;
self.goal_since = None;
self.push_line(&Style::new().fg(TN_GRAY).render(" goal cleared"));
return None;
} else {
self.goal = Some(g.to_string());
self.goal_since = Some(Instant::now());
self.push_line(&gutter(TN_CYAN, &format!("🎯 goal set: {g}")));
return Some(cmd::msg(Msg::Submit(g.to_string())));
}
return None;
}
if let Some(rest) = trimmed.strip_prefix("/loop") {
let task = rest.trim().to_string();
self.textarea.clear();
if task.is_empty() {
self.push_line(
&Style::new().fg(TN_GRAY).render(
" usage: /loop <task> (auto-continues up to 8 turns; Esc stops)",
),
);
return None;
}
self.engage_autonomy(8);
return Some(cmd::msg(Msg::Submit(task)));
}
if let Some(rest) = trimmed
.strip_prefix("/sleep")
.filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))
{
let focus = rest.trim().to_string();
self.textarea.clear();
self.sleep_pending = true;
self.engage_autonomy(8);
self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(" ☾ sleep — consolidating today's work into memory… (Esc stops)"),
);
let directive = panels::sleep::sleep_directive(
&focus,
self.ctx_ready,
&panels::sleep::sleep_today(),
);
let display = if focus.is_empty() {
"☾ sleep".to_string()
} else {
format!("☾ sleep · {focus}")
};
return self.start_stream_inner(directive, display, true, true, false);
}
if let Some(rest) = trimmed
.strip_prefix("/flow")
.filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))
{
let description = rest.trim().to_string();
self.textarea.clear();
if description.is_empty() {
if self.os_session.is_none() {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" /flow needs OS — sign in with /login first"),
);
} else {
self.open_flow_panel();
}
return None;
}
let dir = flow_dir();
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" ⧉ drafting a flow DAG → {} (then /flow opens it in the designer)",
dir.display()
)));
self.engage_autonomy(8);
let prompt = panels::flow::flow_gen_prompt(&description, &dir.to_string_lossy());
let display = format!("⧉ flow: {}", truncate(&description, 60));
return self.start_stream_inner(prompt, display, true, true, false);
}
match trimmed {
"/exit" | "/quit" => return Some(cmd::quit()),
"/fork" => {
self.textarea.clear();
let store = self.store.clone();
let src = self.session_id.clone();
let dst = new_session_id();
return Some(cmd::cmd(move || async move {
match store.load(&src).await {
Ok(Some(mut data)) => {
data.id = dst.clone();
match store.save(&data).await {
Ok(()) => Msg::Forked(Ok(dst)),
Err(e) => Msg::Forked(Err(format!("could not save the fork: {e}"))),
}
}
Ok(None) => Msg::Forked(Err(
"nothing to fork yet — start a conversation first".into(),
)),
Err(e) => Msg::Forked(Err(format!("could not read the session: {e}"))),
}
}));
}
"/clear" => {
self.messages.clear();
self.plan.clear();
self.subagents.clear();
self.queue.clear();
self.completed = 0;
self.textarea.clear();
self.review_pending = false;
self.sleep_pending = false;
self.restore_autonomy();
self.pending_ctx = None;
self.ctx_hits.clear();
let prev_id = std::mem::replace(&mut self.session_id, new_session_id());
let model = self.model.clone();
match self.rebuild_session(model.as_deref()) {
Ok((s, _)) => {
self.session = Arc::new(s);
self.compact_summary = None;
self.output_tokens = 0;
self.last_prompt_tokens = 0;
self.ctx_warned_tier = 0; }
Err(_) => self.session_id = prev_id,
}
self.relayout();
self.rebuild_viewport();
return None;
}
"/init" => {
self.textarea.clear();
self.messages.push(user_bubble(
"/init — generate AGENTS.md",
self.width as usize,
));
self.rebuild_viewport();
return self.start_stream(
"Analyze this codebase and create (or update) an AGENTS.md file at the \
project root. Include: a concise project overview, the exact build / test / \
lint / run commands, the high-level architecture and key directories, and \
the conventions an AI coding agent should follow. Base everything on what's \
actually in the repo, and write the file with your file-writing tool."
.to_string(),
);
}
"/compact" => {
self.textarea.clear();
if self.state != State::Idle {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" finish the current turn before compacting"),
);
return None;
}
let history = self.session.history();
if history.is_empty() {
self.push_line(&Style::new().fg(TN_GRAY).render(" nothing to compact yet"));
return None;
}
self.compacting = Some(Instant::now()); let agent = self.agent.clone();
let workspace = self.cwd.clone();
let prompt = match &self.compact_summary {
Some(prev) => format!(
"An earlier part of this conversation was already condensed into this \
summary:\n\n{prev}\n\nProduce a SINGLE updated summary that fully \
incorporates the summary above AND the conversation history below, so a \
fresh session can continue seamlessly: the goal, key decisions, \
files/commands touched, current state, and the immediate next steps. Be \
thorough but compact."
),
None => "Summarize this conversation so a fresh session can continue \
seamlessly: the goal, key decisions, files/commands touched, current \
state, and the immediate next steps. Be thorough but compact."
.to_string(),
};
return Some(cmd::cmd(move || async move {
let conf = a3s_code_core::hitl::ConfirmationPolicy::enabled()
.with_timeout(500, TimeoutAction::Reject);
let mut summary = String::new();
if let Ok(sess) = agent.session(
workspace,
Some(SessionOptions::new().with_confirmation_policy(conf)),
) {
if let Ok((mut rx, _j)) = sess.stream(&prompt, Some(&history)).await {
while let Some(ev) = rx.recv().await {
match ev {
AgentEvent::TextDelta { text } => summary.push_str(&text),
AgentEvent::End { text, .. } => {
if summary.trim().is_empty() {
summary = text;
}
break;
}
_ => {}
}
}
}
}
Msg::Compacted(summary)
}));
}
"/help" => {
self.textarea.clear();
self.help_open = true;
return None;
}
"/view" => {
self.textarea.clear();
if let Some(spec) = self.last_view.clone() {
self.open_remote_view(&spec);
} else {
self.push_line(&Style::new().fg(TN_GRAY).render(
" no OS view yet — run an OS query that returns a viewUrl, then /view",
));
}
return None;
}
"/auto" => {
self.mode = Mode::Auto;
self.textarea.clear();
self.rebuild_viewport();
return None;
}
"/config" => {
self.textarea.clear();
let path = find_config().map(std::path::PathBuf::from).or_else(|| {
let p = default_config_path()?;
let _ = write_template_config(&p);
Some(p)
});
match path {
Some(p) => self.open_config_in_ide(&p),
None => self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" could not locate a home directory for ~/.a3s/config.acl"),
),
}
return None;
}
"/model" => {
self.textarea.clear();
if let Some(s) = self.os_session.clone() {
let need_fetch = self.os_gateway_models.as_ref().is_none_or(|m| m.is_empty());
if need_fetch {
let (addr, token) = (s.address.clone(), s.access_token.clone());
return Some(cmd::cmd(move || async move {
Msg::OsGatewayModels(
crate::a3s_os::fetch_gateway_models(&addr, &token).await,
)
}));
}
}
self.open_model_menu();
return None;
}
"/effort" => {
self.textarea.clear();
self.effort_panel = Some(self.effort);
return None;
}
"/top" => {
self.textarea.clear();
self.top = Some(Vec::new());
self.top_scroll = 0;
self.top_sel = 0;
self.top_focus = None;
return Some(cmd::cmd(|| async { Msg::TopData(fetch_top().await) }));
}
"/ide" => {
self.textarea.clear();
let entries = ide_children(std::path::Path::new(&self.cwd), 0);
self.ide = Some(Ide::browse(entries, "workspace"));
return None;
}
"/plugin" | "/plugins" => {
self.textarea.clear();
if self.skills.is_empty() {
self.push_line(&Style::new().fg(TN_GRAY).render(
" no skills/plugins found (~/.claude/skills, ~/.codex/skills, ~/.claude/plugins)",
));
} else {
self.plugins_panel = Some(0);
}
return None;
}
"/theme" => {
self.textarea.clear();
let cur = SYNTAX_THEME.load(std::sync::atomic::Ordering::Relaxed);
self.theme_panel = Some(cur.min(THEMES.len() - 1));
return None;
}
"/workflow" => {
self.textarea.clear();
match self.last_workflow.clone() {
Some(doc) => self.open_readonly_in_ide("dynamic-workflow.md", &doc),
None => self.push_line(&Style::new().fg(TN_GRAY).render(
" no dynamic workflow yet — run an ultracode task that fans out via parallel_task",
)),
}
return None;
}
"/review" => {
self.textarea.clear();
if self.review.is_some() {
self.review_open = true;
} else {
self.open_repo_picker(panels::repos::RepoAction::Review);
}
return None;
}
"/deploy" => {
self.textarea.clear();
if self.os_session.is_none() {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" /deploy needs OS — sign in with /login first"),
);
} else {
self.open_repo_picker(panels::repos::RepoAction::Deploy);
}
return None;
}
"/run" => {
self.textarea.clear();
if self.os_session.is_none() {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" /run needs OS — sign in with /login first"),
);
} else {
self.open_repo_picker(panels::repos::RepoAction::Run);
}
return None;
}
"/evolve" => {
self.textarea.clear();
if self.os_session.is_none() {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" /evolve needs OS — sign in with /login first"),
);
} else {
self.open_evolve_panel();
}
return None;
}
"/output" => {
self.textarea.clear();
match self.format_tool_log() {
Some(content) => self.open_readonly_in_ide("tool-calls.txt", &content),
None => self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(" no tool calls yet this session"),
),
}
return None;
}
"/reload" => {
self.textarea.clear();
let dirs = agent_skill_dirs(&self.cwd);
self.skills = load_skills(&dirs);
self.skill_count = count_skill_files(&dirs);
let model = self.model.clone();
match self.rebuild_session(model.as_deref()) {
Ok((session, _)) => {
self.session = Arc::new(session);
self.push_line(&Style::new().fg(TN_GREEN).render(&format!(
" ↻ reloaded — {} skills available",
self.skills.len()
)));
}
Err(error) => {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" reload failed: {error}")),
);
}
}
return None;
}
"/update" => {
self.textarea.clear();
self.updating = Some(Instant::now()); self.relayout();
return Some(cmd::cmd(|| async {
let latest = tokio::task::spawn_blocking(crate::update::fetch_latest)
.await
.ok()
.flatten();
Msg::UpdatePlan(latest)
}));
}
"/git" => {
self.textarea.clear();
self.git = Some(Git {
files: Vec::new(),
sel: 0,
diff: Vec::new(),
diff_scroll: 0,
log: Vec::new(),
log_sel: 0,
view: GitView::Status,
commit_input: None,
note: "loading…".into(),
});
let repo = self.cwd.clone();
return Some(cmd::cmd(move || async move {
let (files, log) = git_status_log(repo).await;
Msg::GitStatus(files, log)
}));
}
"/memory" => {
self.textarea.clear();
let dir = memory_dir();
self.memory = Some(MemPanel {
entries: Vec::new(),
sel: 0,
detail: memutil::MemDetail::default(),
detail_scroll: 0,
dir: dir.clone(),
note: "loading…".into(),
});
return Some(cmd::cmd(move || async move {
let entries = tokio::task::spawn_blocking(move || memutil::load_timeline(&dir))
.await
.unwrap_or_default();
Msg::MemoryLoaded(entries)
}));
}
"/relay" => {
self.textarea.clear();
self.relay.clear();
self.relay_menu = Some(0);
self.relay_tab = 0;
let cwd = self.cwd.clone();
return Some(cmd::cmd(move || async move {
let sessions = tokio::task::spawn_blocking(move || scan_relay(&cwd))
.await
.unwrap_or_default();
Msg::RelayData(sessions)
}));
}
_ => {}
}
self.history.push(trimmed.to_string());
self.history_pos = None;
self.messages
.push(user_bubble(trimmed, self.width as usize));
self.textarea.clear();
let loop_cont = std::mem::take(&mut self.loop_continuation);
let prompt = match (loop_cont, self.pending_ctx.take()) {
(false, Some(c)) => format!("{c}\n\n{trimmed}"),
_ => trimmed.to_string(),
};
if self.state == State::Idle {
self.start_stream(prompt)
} else {
self.seq += 1;
self.queue.push(Queued {
prio: 1,
seq: self.seq,
text: prompt,
});
self.push_line(&Style::new().fg(TN_GRAY).render(" ⋯ queued"));
self.relayout();
None
}
}
fn paste_clipboard_image(&mut self) {
let dest =
std::env::temp_dir().join(format!("a3s-paste-{}.png", self.pending_images.len()));
if !clipboard_image_to(&dest) {
self.push_line(
&Style::new()
.fg(TN_YELLOW)
.render(" no image in clipboard (Ctrl+V pastes a copied/screenshot image)"),
);
return;
}
let Ok(bytes) = std::fs::read(&dest) else {
return;
};
self.messages.push(gutter(
ACCENT,
"📎 pasted image (sends with your next message):",
));
let cols = (self.width as usize).saturating_sub(PAD + 2).min(72);
if let Some(lines) = render_image_file(&dest, cols, 16) {
for l in lines {
self.messages.push(format!("{}{l}", " ".repeat(PAD)));
}
}
self.rebuild_viewport();
self.pending_images
.push(a3s_code_core::llm::Attachment::png(bytes));
}
fn start_stream(&mut self, prompt: String) -> Option<Cmd<Msg>> {
self.start_stream_inner(prompt.clone(), prompt, true, true, false)
}
fn start_ultracode_synthesis(
&mut self,
prompt: String,
display_task: String,
) -> Option<Cmd<Msg>> {
self.ultracode_synthesis_used = true;
self.push_line(&Style::new().fg(TN_GRAY).render(" ⇉ synthesizing results…"));
self.start_stream_inner(prompt, display_task, false, false, true)
}
fn start_stream_inner(
&mut self,
prompt: String,
display_task: String,
clear_turn_artifacts: bool,
include_attachments: bool,
synthesis: bool,
) -> Option<Cmd<Msg>> {
self.streaming.clear();
self.got_delta = false; self.turn_text.clear();
self.turn_had_agent_activity = false;
self.turn_text_after_activity = false;
self.ultracode_synthesis_inflight = synthesis;
if !synthesis {
self.ultracode_synthesis_used = false;
}
self.last_paint = None; self.viewport.set_auto_scroll(true); if clear_turn_artifacts {
self.plan.clear(); self.subagents.clear(); }
self.running_task = Some(display_task);
self.state = State::Streaming;
self.relayout();
self.stream_started = Some(Instant::now());
self.spinner.start();
self.rebuild_viewport();
let session = self.session.clone();
let atts = if include_attachments {
std::mem::take(&mut self.pending_images)
} else {
Vec::new()
};
let prompt = match &self.goal {
Some(g) => format!("[Ongoing goal: {g}]\n\n{prompt}"),
None => prompt,
};
Some(cmd::batch(vec![
cmd::cmd(move || async move {
let res = if atts.is_empty() {
session.stream(prompt.as_str(), None).await
} else {
session
.stream_with_attachments(prompt.as_str(), &atts, None)
.await
};
match res {
Ok((rx, _join)) => Msg::StreamStarted(Arc::new(Mutex::new(rx))),
Err(e) => Msg::StreamError(e.to_string()),
}
}),
spinner_tick(),
]))
}
fn drain_queue(&mut self) -> Option<Cmd<Msg>> {
let next = self.queue.pop()?;
self.start_stream(next.text)
}
fn complete_turn(&mut self) -> Option<Cmd<Msg>> {
if self.state == State::Streaming {
self.completed += 1;
}
let synthesis = self.prepare_ultracode_synthesis();
self.finish();
if let Some((prompt, display_task)) = synthesis {
return self.start_ultracode_synthesis(prompt, display_task);
}
if self.loop_remaining > 0 && self.queue.is_empty() {
self.loop_remaining -= 1;
let n = self.loop_remaining;
self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(&format!(" ↻ loop ({n} left · Esc to stop)")),
);
self.loop_continuation = true;
return Some(cmd::msg(Msg::Submit(
"Continue. If the task is fully complete, reply DONE and stop.".to_string(),
)));
}
if self.loop_remaining == 0 {
self.restore_autonomy();
}
self.drain_queue()
}
fn engage_autonomy(&mut self, budget: usize) {
self.loop_remaining = self.loop_remaining.max(budget);
if self.mode != Mode::Auto {
self.autonomy_restore = Some(self.mode);
self.mode = Mode::Auto;
self.push_line(&Style::new().fg(TN_GRAY).render(
" ⏵⏵ auto mode engaged for this task — restores when it completes (Esc stops)",
));
}
}
fn restore_autonomy(&mut self) {
if let Some(prev) = self.autonomy_restore.take() {
self.mode = prev;
self.push_line(
&Style::new()
.fg(TN_GRAY)
.render(" ⏵ autonomous task ended — auto mode restored to your previous mode"),
);
}
}
fn on_agent_event(&mut self, event: AgentEvent) -> Option<Cmd<Msg>> {
self.rx.as_ref()?;
match event {
AgentEvent::TextDelta { text } => {
self.mark_assistant_text(&text);
self.got_delta = true;
self.turn_text.push_str(&text);
self.streaming.push(&text);
self.update_viewport_with_stream();
}
AgentEvent::ReasoningDelta { text } => {
self.thinking.push_str(&text);
self.update_viewport_with_stream();
}
AgentEvent::ToolStart { name, .. } => {
self.mark_agent_activity();
self.finalize_streaming();
self.tool_args.clear();
self.tool_output.clear();
self.active_tools += 1;
self.running_tool = Some(name);
}
AgentEvent::ToolInputDelta { delta } => {
self.tool_args.push_str(&delta);
}
AgentEvent::ToolOutputDelta { delta, .. } => {
self.tool_output.push_str(&delta);
self.update_viewport_with_stream();
}
AgentEvent::ToolEnd {
name,
output,
exit_code,
metadata,
..
} => {
self.mark_agent_activity();
self.running_tool = None;
self.active_tools = self.active_tools.saturating_sub(1);
let args: Option<serde_json::Value> = serde_json::from_str(&self.tool_args).ok();
self.push_line(&render_tool_end(
&name,
exit_code,
&output,
metadata.as_ref(),
args.as_ref(),
self.width as usize,
));
self.capture_workflow(&name, args.as_ref());
let os_origin = self
.os_session
.as_ref()
.map(|s| crate::a3s_os::os_origin(&s.address));
if let Some(spec) = remote_ui::find_view_url(&output, os_origin.as_deref()) {
let is_new = self.last_view.as_ref() != Some(&spec);
self.last_view = Some(spec);
if is_new {
self.push_line(&gutter(
TN_CYAN,
&format!("🔗 {VIEW_BUTTON_MARKER} (click or /view to open)"),
));
}
}
let logged = if output.len() > 8192 {
let mut s: String = output.chars().take(8000).collect();
s.push_str("\n… (output truncated — see transcript)");
s
} else {
output
};
self.tool_log.push(ToolCallRecord {
name,
args,
output: logged,
exit_code,
});
self.tool_args.clear();
self.tool_output.clear();
}
AgentEvent::SubagentStart {
task_id,
agent,
description,
..
} => {
self.mark_agent_activity();
self.finalize_streaming();
self.active_agents += 1;
self.subagents.push(SubAgent {
task_id,
agent,
description,
started: Instant::now(),
ended: None,
tokens: 0,
done: false,
success: None,
});
self.relayout();
}
AgentEvent::SubagentProgress {
task_id, metadata, ..
} => {
self.mark_agent_activity();
let toks = metadata
.get("completion_tokens")
.or_else(|| metadata.pointer("/usage/completion_tokens"))
.and_then(|v| v.as_u64());
if let Some(s) = self.subagents.iter_mut().find(|s| s.task_id == task_id) {
if let Some(t) = toks {
s.tokens += t;
}
}
}
AgentEvent::SubagentEnd {
task_id,
agent,
output,
success,
..
} => {
self.mark_agent_activity();
self.active_agents = self.active_agents.saturating_sub(1);
if let Some(s) = self.subagents.iter_mut().find(|s| s.task_id == task_id) {
s.done = true;
s.success = Some(success);
s.ended = Some(Instant::now());
}
self.relayout();
let (mark, color) = if success {
("✓", TN_GREEN)
} else {
("✗", TN_RED)
};
let snippet = output.lines().next().unwrap_or("").trim();
let snippet = truncate(snippet, self.width.saturating_sub(20) as usize);
self.push_line(&Style::new().fg(color).render(&format!(
" ⇉ {mark} {agent}{}",
if snippet.is_empty() {
String::new()
} else {
format!(" · {snippet}")
}
)));
}
AgentEvent::ContextCompacted {
before_messages,
after_messages,
percent_before,
..
} => {
if after_messages < before_messages {
let pct = if self.context_limit > 0 {
(percent_before * CORE_MAX_CONTEXT_TOKENS * 100.0
/ self.context_limit as f32)
.round()
.min(100.0) as u32
} else {
(percent_before * 100.0).round() as u32
};
self.push_line(&Style::new().fg(TN_GRAY).italic().render(&format!(
" ✦ context auto-compacted at {pct}% · {before_messages} → {after_messages} messages"
)));
}
}
AgentEvent::ConfirmationRequired {
tool_id,
tool_name,
args,
..
} => {
if self.mode.auto_approves(&tool_name) {
let session = self.session.clone();
return Some(cmd::cmd(move || async move {
let _ = session.confirm_tool_use(&tool_id, true, None).await;
Msg::Resume
}));
}
self.state = State::Awaiting;
self.approval_sel = 0;
let label = tool_label(&tool_name, Some(&args));
self.pending_tool = Some((tool_id, label));
return None; }
AgentEvent::TurnEnd { usage, .. } => {
if usage.prompt_tokens > 0 {
self.last_prompt_tokens = usage.prompt_tokens;
self.maybe_warn_ctx();
}
}
AgentEvent::End {
text, usage, meta, ..
} => {
if self.loop_remaining > 0 && !self.sleep_pending {
let r = if text.is_empty() {
self.streaming.raw_content().to_string()
} else {
text.clone()
};
if r.split(|c: char| !c.is_alphabetic())
.any(|w| w.eq_ignore_ascii_case("done"))
{
self.loop_remaining = 0;
}
}
let review_text = if text.is_empty() {
self.turn_text.clone()
} else {
text.clone()
};
if !self.got_delta && !text.is_empty() {
self.mark_assistant_text(&text);
self.streaming.push(&text);
}
self.finalize_streaming();
self.capture_review(&review_text);
let sleep_save = self.capture_sleep(&review_text);
self.disarm_sleep_if_over(sleep_save.is_some());
self.output_tokens += if usage.completion_tokens > 0 {
usage.completion_tokens
} else {
usage.total_tokens.saturating_sub(usage.prompt_tokens)
};
if self.model.is_none() {
self.model = meta.and_then(|m| m.response_model.or(m.request_model));
}
return match (sleep_save, self.complete_turn()) {
(Some(save), Some(next)) => Some(cmd::batch(vec![save, next])),
(save, next) => save.or(next),
};
}
AgentEvent::Error { message } => {
self.push_line(
&Style::new()
.fg(TN_RED)
.render(&format!(" error: {message}")),
);
self.loop_remaining = 0; self.review_pending = false; self.sleep_pending = false; self.restore_autonomy();
self.finish();
return self.drain_queue();
}
AgentEvent::PlanningEnd { plan, .. } => {
self.mark_agent_activity();
self.set_plan(&plan.steps);
}
AgentEvent::TaskUpdated { tasks, .. } => {
self.mark_agent_activity();
self.set_plan(&tasks);
}
AgentEvent::StepStart { step_id, .. } => {
self.mark_agent_activity();
self.set_task_status(&step_id, '▶', TN_YELLOW);
}
AgentEvent::StepEnd {
step_id, status, ..
} => {
self.mark_agent_activity();
let (g, c) = task_status_style(status);
self.set_task_status(&step_id, g, c);
}
_ => {}
}
self.rx.clone().map(pump)
}
fn maybe_warn_ctx(&mut self) {
if self.context_limit == 0 {
return;
}
let pct = (self.last_prompt_tokens * 100 / self.context_limit as usize).min(100);
let (latch, warn) = ctx_warn_tier(pct, self.ctx_warned_tier);
self.ctx_warned_tier = latch;
if warn.is_some() {
self.finalize_streaming();
}
match warn {
Some(85) => self.push_line(&Style::new().fg(TN_RED).render(&format!(
" ✦ context {pct}% full — auto-compacting soon; /compact to summarize now"
))),
Some(_) => self.push_line(&Style::new().fg(TN_YELLOW).render(&format!(
" ✦ context {pct}% full — auto-compacts near 85%; /compact to summarize early"
))),
None => {}
}
}
fn mark_agent_activity(&mut self) {
self.turn_had_agent_activity = true;
self.turn_text_after_activity = false;
}
fn mark_assistant_text(&mut self, text: &str) {
if !text.trim().is_empty() {
self.turn_text_after_activity = true;
}
}
fn prepare_ultracode_synthesis(&self) -> Option<(String, String)> {
if !needs_synthesis(
self.ultracode_synthesis_inflight,
self.ultracode_synthesis_used,
self.turn_had_agent_activity,
self.turn_text_after_activity,
) {
return None;
}
let user_task = self
.running_task
.as_deref()
.filter(|task| !task.trim().is_empty())
.unwrap_or("the previous task");
let mut prompt = format!(
"[synthesis]\n\
The previous turn completed planning/tool/subagent work \
but stopped without a final user-facing answer.\n\n\
Original user task:\n{user_task}\n\n\
Write the final answer now in the user's language. Synthesize the \
completed work into a useful response. Do not call tools or start \
more subagents unless it is strictly necessary to avoid an incorrect \
answer. If a child run produced no text output, summarize the \
available plan/status instead of exposing raw task metadata.\n"
);
if !self.plan.is_empty() {
prompt.push_str("\nPlan/status:\n");
for (_, text, glyph, _) in &self.plan {
let status = match glyph {
'✔' => "done",
'▶' => "in progress",
'✗' => "failed",
_ => "pending",
};
prompt.push_str(&format!("- [{status}] {text}\n"));
}
}
if !self.subagents.is_empty() {
prompt.push_str("\nSubagents:\n");
for agent in &self.subagents {
let status = match agent.success {
Some(true) => "done",
Some(false) => "failed",
None if agent.done => "done",
None => "unknown",
};
prompt.push_str(&format!(
"- [{status}] {}: {}\n",
agent.agent, agent.description
));
}
}
if let Some(workflow) = &self.last_workflow {
prompt.push_str("\nLatest workflow artifact excerpt:\n");
prompt.push_str(&truncate(workflow, 4000));
prompt.push('\n');
}
Some((prompt, user_task.to_string()))
}
fn finalize_streaming(&mut self) {
let rendered = self.streaming.view();
if !rendered.trim().is_empty() {
let block = gutter(TN_GREEN, &rendered);
let recent_dup = self.messages.iter().rev().take(4).any(|m| m == &block);
if !recent_dup {
self.messages.push(block);
}
}
self.streaming.clear();
self.thinking.clear();
self.rebuild_viewport();
}
fn finish(&mut self) {
self.state = State::Idle;
self.running_task = None;
self.active_tools = 0;
self.active_agents = 0;
self.subagents.clear();
self.ultracode_synthesis_inflight = false;
self.relayout();
self.stream_started = None;
self.spinner.stop();
self.rx = None;
self.rebuild_viewport();
}
fn push_line(&mut self, line: &str) {
self.messages.push(line.to_string());
self.rebuild_viewport();
}
fn open_remote_view(&mut self, spec: &remote_ui::ViewSpec) {
if remote_ui::open_window(spec).is_err() {
self.push_line(&Style::new().fg(TN_GRAY).render(&format!(
" 🔗 open in your browser: {} (install a3s-webview for an in-app window, macOS)",
spec.url
)));
}
}
pub(crate) fn skill_dirs(&self) -> Vec<std::path::PathBuf> {
let mut dirs = agent_skill_dirs(&self.cwd);
if let Some(d) = ensure_builtin_skills_dir() {
dirs.push(d);
}
if self.os_session.is_some() {
if let Some(cfg) = &self.os_config {
if let Some(d) = crate::a3s_os::ensure_capability_skill_dir(cfg) {
dirs.push(d);
}
}
}
dirs
}
fn refresh_after_auth(&mut self) {
if self.state == State::Idle {
if let Ok((s, _)) = self.rebuild_session(self.model.as_deref()) {
self.session = Arc::new(s);
}
}
self.sync_runtime_tool();
let dirs = self.skill_dirs();
self.skill_count = count_skill_files(&dirs);
self.skills = load_skills(&dirs);
}
fn sync_runtime_tool(&self) {
match self.os_session.as_ref() {
Some(s) => self.session.register_dynamic_tool(std::sync::Arc::new(
crate::runtime_tool::RuntimeTool::new(s),
)),
None => self.session.unregister_dynamic_tool("runtime"),
}
}
fn open_config_in_ide(&mut self, path: &std::path::Path) {
let dir = path.parent().unwrap_or(std::path::Path::new("."));
let lines: Vec<String> = std::fs::read_to_string(path)
.unwrap_or_default()
.replace('\t', " ")
.lines()
.map(String::from)
.collect();
let mut ide = Ide::browse(ide_children(dir, 0), "config");
ide.file = Some(IdeFile::new(path.to_path_buf(), lines, false, false));
ide.focus_editor = true;
self.ide = Some(ide);
}
fn capture_workflow(&mut self, name: &str, args: Option<&serde_json::Value>) {
let Some((doc, label)) = workflow_doc_for_tool(name, args) else {
return;
};
self.last_workflow = Some(doc);
self.push_line(&Style::new().fg(ACCENT).render(&format!(" ⊞ {label}")));
}
fn open_readonly_in_ide(&mut self, title: &str, content: &str) {
let lines: Vec<String> = content.lines().map(String::from).collect();
let mut ide = Ide::browse(
ide_children(std::path::Path::new(&self.cwd), 0),
"workspace",
);
ide.file = Some(IdeFile::new(
std::path::PathBuf::from(title),
lines,
false,
true,
));
ide.focus_editor = true;
ide.flash = Some("read-only".to_string());
self.ide = Some(ide);
}
fn format_tool_log(&self) -> Option<String> {
format_tool_log_records(&self.tool_log)
}
fn history_recall(&mut self, up: bool) {
let pos = match (self.history_pos, up) {
(None, true) => self.history.len().saturating_sub(1),
(None, false) => return,
(Some(i), true) => i.saturating_sub(1),
(Some(i), false) => i + 1,
};
if pos >= self.history.len() {
self.history_pos = None;
self.textarea.clear();
} else {
self.history_pos = Some(pos);
self.textarea.set_value(&self.history[pos]);
}
}
fn update_viewport_with_stream(&mut self) {
if let Some(t) = self.last_paint {
if t.elapsed() < Duration::from_millis(33) {
return;
}
}
self.last_paint = Some(Instant::now());
let mut blocks: Vec<String> = self.messages.clone();
if !self.thinking.trim().is_empty() {
let dim = Style::new().fg(TN_GRAY).italic();
let margin = " ".repeat(PAD);
let avail = (self.width as usize).saturating_sub(PAD + 3).max(8);
let body = wrap_words(self.thinking.trim(), avail)
.iter()
.enumerate()
.map(|(i, line)| {
let lead = if i == 0 { "💭 " } else { " " };
format!("{margin}{}", dim.render(&format!("{lead}{line}")))
})
.collect::<Vec<_>>()
.join("\n");
blocks.push(body);
}
let rendered = self.streaming.view();
if !rendered.is_empty() {
blocks.push(gutter(TN_GREEN, &rendered));
}
if let Some(name) = &self.running_tool {
let args: Option<serde_json::Value> = serde_json::from_str(&self.tool_args).ok();
let verb = match name.as_str() {
"bash" | "shell" | "run" | "exec" => "Running",
_ => tool_verb(name),
};
let arg = args.as_ref().and_then(arg_summary).unwrap_or_default();
let on = self.blink_tick % 8 < 4; let dot = Style::new()
.fg(if on { TN_YELLOW } else { TN_GRAY })
.bold()
.render("•");
let m = " ".repeat(PAD);
blocks.push(if arg.is_empty() {
format!("{m}{dot} {verb}…")
} else {
format!("{m}{dot} {verb} {arg}…")
});
}
if !self.tool_output.trim().is_empty() {
let m = " ".repeat(PAD + 2);
let bar = Style::new().fg(TN_GRAY).render("│");
let tail: Vec<&str> = self.tool_output.lines().rev().take(12).collect();
let body = tail
.into_iter()
.rev()
.map(|l| format!("{m}{bar} {}", Style::new().fg(TN_GRAY).render(l)))
.collect::<Vec<_>>()
.join("\n");
blocks.push(body);
}
self.viewport
.set_content(&format!("\n{}\n", blocks.join("\n\n")));
}
fn rebuild_viewport(&mut self) {
self.selection = None; let full = self.messages.join("\n\n");
self.viewport.set_content(&format!("\n{full}\n")); }
pub(crate) fn input_height(&self) -> u16 {
self.textarea.height()
}
fn handle_approval_key(&mut self, key: &KeyEvent) -> Option<Cmd<Msg>> {
match key.code {
KeyCode::Up => {
self.approval_sel = self.approval_sel.saturating_sub(1);
None
}
KeyCode::Down => {
self.approval_sel = (self.approval_sel + 1).min(2);
None
}
KeyCode::Enter => Some(cmd::msg(self.apply_approval(self.approval_sel))),
KeyCode::Char('y' | 'Y') => Some(cmd::msg(self.apply_approval(0))),
KeyCode::Char('a' | 'A') => Some(cmd::msg(self.apply_approval(1))),
KeyCode::Char('n' | 'N') | KeyCode::Esc => Some(cmd::msg(self.apply_approval(2))),
KeyCode::Char(c @ '1'..='3') => {
Some(cmd::msg(self.apply_approval(c as usize - '1' as usize)))
}
_ => None,
}
}
fn apply_approval(&mut self, choice: usize) -> Msg {
match choice {
0 => Msg::ModalConfirm(0), 1 => {
self.mode = Mode::Auto; Msg::ModalConfirm(0)
}
_ => Msg::ModalConfirm(1), }
}
fn overlay_approval(&self, composed: String) -> String {
if self.state != State::Awaiting {
return composed;
}
let Some((_, label)) = &self.pending_tool else {
return composed;
};
let width = self.width as usize;
let opts = ["Yes", "Yes, and don't ask again", "No"];
let mut menu = vec![pad_to(
&Style::new()
.fg(TN_YELLOW)
.bold()
.render(&format!(" ⏵ Allow {label}?")),
width,
)];
for (i, o) in opts.iter().enumerate() {
let marker = if i == self.approval_sel { "❯" } else { " " };
let raw = pad_to(&format!(" {marker} {}. {o}", i + 1), width);
menu.push(if i == self.approval_sel {
Style::new().fg(Color::BrightWhite).bg(ACCENT).render(&raw)
} else {
Style::new().fg(TN_FG).render(&raw)
});
}
menu.push(pad_to(
&Style::new()
.fg(TN_GRAY)
.render(" Enter select · ↑/↓ · 1–3 · Esc"),
width,
));
self.overlay_list(composed, &menu)
}
}
async fn run_smoke(session: Arc<AgentSession>) -> anyhow::Result<()> {
let prompt = std::env::var("A3S_CODE_TUI_PROMPT")
.unwrap_or_else(|_| "Reply with exactly one short sentence: what is 2 + 2?".to_string());
eprintln!("[smoke] prompt: {prompt}");
let (mut rx, join) = session.stream(prompt.as_str(), None).await?;
while let Some(event) = rx.recv().await {
match event {
AgentEvent::TextDelta { text } => print!("{text}"),
AgentEvent::ToolStart { name, .. } => eprintln!("\n[tool start] {name}"),
AgentEvent::ToolEnd {
name,
exit_code,
output,
..
} => eprintln!(
"[tool end] {name} (exit {exit_code}): {}",
output.lines().take(2).collect::<Vec<_>>().join(" | ")
),
AgentEvent::ConfirmationRequired {
tool_id, tool_name, ..
} => {
eprintln!("[confirm] auto-allowing {tool_name}");
let _ = session.confirm_tool_use(&tool_id, true, None).await;
}
AgentEvent::End { .. } => eprintln!("\n[end]"),
AgentEvent::Error { message } => eprintln!("\n[error] {message}"),
_ => {}
}
}
let _ = join.await;
Ok(())
}
pub async fn run(args: Vec<String>) -> anyhow::Result<()> {
let resuming = args.first().map(String::as_str) == Some("resume");
let explicit_id = if resuming { args.get(1).cloned() } else { None };
let mut session_id = explicit_id.clone().unwrap_or_else(new_session_id);
let (config_path, created_config) = match find_config() {
Some(p) => (p, false),
None => {
let p = default_config_path()
.ok_or_else(|| anyhow::anyhow!("no HOME directory found for ~/.a3s/config.acl"))?;
write_template_config(&p)
.map_err(|e| anyhow::anyhow!("failed to write starter config {p:?}: {e}"))?;
(p.to_string_lossy().into_owned(), true)
}
};
let agent = Arc::new(
Agent::new(config_path.clone())
.await
.map_err(|e| anyhow::anyhow!("failed to load agent from {config_path}: {e}"))?,
);
let workspace = std::env::current_dir()?.to_string_lossy().to_string();
let mut models: Vec<String> = Vec::new();
let mut model_ctx: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut default_model: Option<String> = None;
let mut os_config: Option<OsConfig> = None;
if let Ok(cfg) =
a3s_code_core::config::CodeConfig::from_file(std::path::Path::new(&config_path))
{
for (p, m) in cfg.list_models() {
let id = format!("{}/{}", p.name, m.id);
model_ctx.insert(id.clone(), m.limit.context);
models.push(id);
}
default_model = cfg.default_model.clone();
os_config = cfg.os.clone();
}
let context_limit = resolve_ctx_limit(
default_model
.as_ref()
.and_then(|m| model_ctx.get(m))
.copied(),
);
let store_dir = std::path::Path::new(&workspace).join(".a3s/tui-sessions");
if resuming {
let mut saved: Vec<(String, std::time::SystemTime)> = std::fs::read_dir(&store_dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("json") {
return None;
}
let id = p.file_stem()?.to_str()?.to_string();
let mtime = e.metadata().ok()?.modified().ok()?;
Some((id, mtime))
})
.collect();
saved.sort_by_key(|e| std::cmp::Reverse(e.1)); match &explicit_id {
Some(id) if !saved.iter().any(|(s, _)| s == id) => {
eprintln!("a3s: session '{id}' not found in {}", store_dir.display());
if saved.is_empty() {
eprintln!(" (no saved sessions in this directory)");
} else {
eprintln!(" available sessions (newest first):");
for (s, _) in saved.iter().take(10) {
eprintln!(" a3s code resume {s}");
}
}
return Ok(());
}
None => match saved.first() {
Some((s, _)) => session_id = s.clone(),
None => {
eprintln!(
"a3s: no saved sessions to resume in {}",
store_dir.display()
);
return Ok(());
}
},
_ => {}
}
}
let store: Arc<dyn a3s_code_core::store::SessionStore> = Arc::new(
a3s_code_core::store::FileSessionStore::new(&store_dir)
.await
.map_err(|e| anyhow::anyhow!("failed to open session store {store_dir:?}: {e}"))?,
);
let confirmation = a3s_code_core::hitl::ConfirmationPolicy::enabled()
.with_timeout(3_600_000, TimeoutAction::Reject);
let mut claude_dirs = agent_skill_dirs(&workspace);
let os_session = os_config.as_ref().and_then(crate::a3s_os::current_session);
if let Some(s) = &os_session {
crate::a3s_os::export_os_env(s);
if let Some(dir) = os_config
.as_ref()
.and_then(crate::a3s_os::ensure_capability_skill_dir)
{
claude_dirs.push(dir);
}
}
let instructions = project_instructions(&workspace);
let os_address = os_session.as_ref().map(|s| s.address.clone());
let ctx_ready = panels::ctx::ctx_available();
let with_instr = |o: SessionOptions| {
let mut parts: Vec<String> = Vec::new();
if let Some(i) = &instructions {
parts.push(i.clone());
}
if let Some(addr) = &os_address {
parts.push(os_platform_guide(addr));
}
if ctx_ready {
parts.push(panels::ctx::ctx_history_guide());
}
if parts.is_empty() {
o
} else {
o.with_prompt_slots(SystemPromptSlots::default().with_extra(parts.join("\n\n")))
}
};
let manifest_backend = ManifestWorkspaceBackend::new(std::path::PathBuf::from(&workspace));
let workspace_manifest = manifest_backend.manifest();
let initial_manifest = workspace_manifest.snapshot();
let initial_files = initial_manifest.file_paths();
let workspace_manifest_rx = Arc::new(Mutex::new(workspace_manifest.subscribe()));
let workspace_services = WorkspaceServices::local_with_manifest_backend(manifest_backend);
let session = match agent.resume_session(
session_id.as_str(),
with_instr(with_recent_workspace_context(
SessionOptions::new()
.with_session_store(store.clone())
.with_confirmation_policy(confirmation.clone())
.with_workspace_backend(workspace_services.clone())
.with_skill_dirs(claude_dirs.clone())
.with_auto_save(true)
.with_auto_compact(true)
.with_auto_compact_threshold(auto_compact_threshold_for(context_limit))
.with_file_memory(memory_dir())
.with_max_parallel_tasks(8)
.with_auto_delegation_enabled(true)
.with_auto_parallel_delegation(true)
.with_manual_delegation_enabled(true),
&workspace_manifest,
)),
) {
Ok(s) => s,
Err(_) => agent.session(
workspace.clone(),
Some(with_instr(with_recent_workspace_context(
SessionOptions::new()
.with_session_store(store.clone())
.with_session_id(session_id.as_str())
.with_confirmation_policy(confirmation.clone())
.with_workspace_backend(workspace_services.clone())
.with_skill_dirs(claude_dirs.clone())
.with_auto_save(true)
.with_auto_compact(true)
.with_auto_compact_threshold(auto_compact_threshold_for(context_limit))
.with_file_memory(memory_dir())
.with_max_parallel_tasks(8)
.with_auto_delegation_enabled(true)
.with_auto_parallel_delegation(true)
.with_manual_delegation_enabled(true),
&workspace_manifest,
))),
)?,
};
if let Some(os) = os_session.as_ref() {
session.register_dynamic_tool(std::sync::Arc::new(crate::runtime_tool::RuntimeTool::new(
os,
)));
}
let (width, height) = a3s_tui::terminal::Terminal::size().unwrap_or((80, 24));
let resumed = session.history();
let mut initial_messages: Vec<String> = resumed
.iter()
.filter_map(|m| {
let text = m.text();
if text.trim().is_empty() {
return None;
}
match m.role.as_str() {
"user" => Some(gutter(ACCENT, text.trim())),
"assistant" => {
let mut md = StreamingMarkdown::new((width as usize).saturating_sub(PAD + 2));
md.push(&text);
Some(gutter(TN_GREEN, &md.view()))
}
_ => None,
}
})
.collect();
let history_seed: Vec<String> = resumed
.iter()
.filter(|m| m.role == "user")
.map(|m| m.text().trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if let Some(s) = &os_session {
if !initial_messages.is_empty() {
initial_messages.insert(
0,
Style::new().fg(TN_GRAY).render(&format!(
" ✓ signed in to OS as {} · capabilities skill active · /logout to sign out",
s.display_label()
)),
);
}
}
let session = Arc::new(session);
if std::env::var_os("A3S_CODE_TUI_SMOKE").is_some() {
return run_smoke(session).await;
}
let keymap = Keymap::new()
.bind(
KeyBinding::new(KeyCode::PageUp),
Action::ScrollUp,
"Scroll up",
)
.bind(
KeyBinding::new(KeyCode::PageDown),
Action::ScrollDown,
"Scroll down",
)
.bind(
KeyBinding::ctrl(KeyCode::Home),
Action::ScrollTop,
"Scroll to top",
)
.bind(
KeyBinding::ctrl(KeyCode::End),
Action::ScrollBottom,
"Scroll to bottom",
);
let mut app = App {
session,
agent: agent.clone(),
store: store.clone(),
confirmation,
session_id: session_id.clone(),
models,
relay: Vec::new(),
relay_menu: None,
relay_tab: 0,
model_ctx,
context_limit,
last_prompt_tokens: 0,
ctx_warned_tier: 0,
model_menu: None,
model_tab: 0,
llm_override: None,
os_config,
os_session,
os_refreshing: false,
os_gateway_models: None,
os_gateway_error: None,
last_view: None,
effort: 2, effort_panel: None,
theme_panel: None,
quit_armed: None,
last_activity: Instant::now(),
auto_reviewed: false,
shell_mode: false,
research_mode: false,
review_mode: false,
review_pending: false,
sleep_pending: false,
review: None,
review_open: false,
repo_picker: None,
flow: None,
autonomy_restore: None,
evolve: None,
evolve_mode: false,
evolve_target: None,
ctx_ready,
ctx_hits: Vec::new(),
pending_ctx: None,
loop_continuation: false,
turn_text: String::new(),
selection: None,
last_workflow: None,
pending_images: Vec::new(),
goal: None,
goal_since: None,
loop_remaining: 0,
active_tools: 0,
active_agents: 0,
subagents: Vec::new(),
turn_had_agent_activity: false,
turn_text_after_activity: false,
ultracode_synthesis_inflight: false,
ultracode_synthesis_used: false,
instructions,
workspace_manifest,
workspace_manifest_rx,
workspace_services,
rainbow_until: None,
rainbow_frame: 0,
effort_anim: None,
compact_summary: None,
btw: None,
viewport: Viewport::new(width.saturating_sub(1), height.saturating_sub(7)),
textarea: Textarea::new()
.with_height(1)
.with_auto_grow(8) .with_width(width.saturating_sub((PAD + 2) as u16)) .with_submit_on_enter(true),
spinner: Spinner::new().with_title(""),
streaming: StreamingMarkdown::new((width as usize).saturating_sub(PAD + 2)),
got_delta: false,
compacting: None,
updating: None,
last_paint: None,
thinking: String::new(),
state: State::Idle,
messages: initial_messages,
rx: None,
pending_tool: None,
approval_sel: 0,
history: history_seed,
history_pos: None,
model: default_model,
output_tokens: 0,
tool_args: String::new(),
tool_output: String::new(),
tool_log: Vec::new(),
stream_started: None,
running_tool: None,
blink_tick: 0,
anim: 0,
mode: Mode::Default,
queue: BinaryHeap::new(),
seq: 0,
running_task: None,
plan: Vec::new(),
top: None,
top_scroll: 0,
top_sel: 0,
top_focus: None,
top_kill: None,
ide: None,
chat: None,
git: None,
memory: None,
help_open: false,
completed: 0,
branch: git_branch(&workspace),
slash_sel: 0,
files: initial_files,
at_expanded: std::collections::HashSet::new(),
file_sel: 0,
skill_count: count_skill_files(&claude_dirs),
skills: load_skills(&claude_dirs),
disabled_skills: load_disabled_skills(),
plugins_panel: None,
update_available: None,
cwd: workspace.clone(),
width,
height,
keymap,
};
if created_config {
app.messages.push(gutter(
ACCENT,
"Welcome to a3s code! Generated a starter ~/.a3s/config.acl — fill in your \
provider apiKey/baseUrl + model, Ctrl+S to save, Esc to close, then restart \
`a3s code` to load it.",
));
app.open_config_in_ide(std::path::Path::new(&config_path));
app.rebuild_viewport();
}
let launch_model = app.model.clone();
if let Ok((s, _)) = app.rebuild_session(launch_model.as_deref()) {
app.session = Arc::new(s);
}
ProgramBuilder::new(app)
.with_alt_screen()
.with_mouse_support()
.with_fps(30)
.run()
.await?;
if UPGRADE_ON_EXIT.load(std::sync::atomic::Ordering::Relaxed) {
let latest = LATEST
.lock()
.ok()
.and_then(|g| g.clone())
.unwrap_or_default();
match crate::update::perform_upgrade(&latest) {
Some(bin) => {
let restart_args = ["code", "resume", session_id.as_str()];
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let _ = std::process::Command::new(&bin).args(restart_args).exec();
if let Ok(exe) = std::env::current_exe() {
let _ = std::process::Command::new(exe).args(restart_args).exec();
}
}
#[cfg(not(unix))]
{
let _ = std::process::Command::new(&bin).args(restart_args).status();
}
}
None => eprintln!(
"\n✗ upgrade failed — get the latest from https://github.com/A3S-Lab/Cli/releases/latest\n"
),
}
return Ok(());
}
println!("\n session saved · resume it with: a3s code resume {session_id}\n");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effort_ladder_is_monotonic_and_well_formed() {
assert_eq!(ULTRACODE, EFFORT_LEVELS.len() - 1);
assert_eq!(EFFORT_LEVELS[ULTRACODE].label, "ultracode");
for w in EFFORT_LEVELS[..=ULTRACODE].windows(2) {
assert!(
w[1].thinking_budget >= w[0].thinking_budget,
"thinking budget regressed"
);
assert!(
w[1].max_tool_rounds >= w[0].max_tool_rounds,
"tool-round budget regressed"
);
assert!(
w[1].max_continuation_turns >= w[0].max_continuation_turns,
"continuation budget regressed"
);
}
assert!(
EFFORT_LEVELS[1].guideline.is_none(),
"medium should be the baseline"
);
for (i, p) in EFFORT_LEVELS.iter().enumerate() {
if i != 1 {
assert!(
p.guideline.is_some(),
"level {} has no depth steer",
p.label
);
}
}
}
use a3s_code_core::llm::{
ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
};
use async_trait::async_trait;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Default)]
struct CapturedLlmTurn {
system: Option<String>,
tools: Vec<String>,
}
struct CaptureLlmClient {
turns: Mutex<Vec<CapturedLlmTurn>>,
responses: Mutex<VecDeque<LlmResponse>>,
}
#[async_trait]
impl LlmClient for CaptureLlmClient {
async fn complete(
&self,
_messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
) -> anyhow::Result<LlmResponse> {
self.record(system, tools);
Ok(self.next_response())
}
async fn complete_streaming(
&self,
_messages: &[Message],
system: Option<&str>,
tools: &[ToolDefinition],
_cancel_token: CancellationToken,
) -> anyhow::Result<mpsc::Receiver<StreamEvent>> {
self.record(system, tools);
let response = self.next_response();
let (tx, rx) = mpsc::channel(2);
tokio::spawn(async move {
let _ = tx.send(StreamEvent::Done(response)).await;
});
Ok(rx)
}
}
impl CaptureLlmClient {
fn new(responses: Vec<LlmResponse>) -> Self {
Self {
turns: Mutex::new(Vec::new()),
responses: Mutex::new(responses.into()),
}
}
fn record(&self, system: Option<&str>, tools: &[ToolDefinition]) {
self.turns.lock().unwrap().push(CapturedLlmTurn {
system: system.map(str::to_string),
tools: tools.iter().map(|tool| tool.name.clone()).collect(),
});
}
fn next_response(&self) -> LlmResponse {
self.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(done_response)
}
fn turns(&self) -> Vec<CapturedLlmTurn> {
self.turns.lock().unwrap().clone()
}
}
fn tool_call_response(name: &str, input: serde_json::Value) -> LlmResponse {
LlmResponse {
message: Message {
role: "assistant".into(),
content: vec![ContentBlock::ToolUse {
id: "toolu_test".into(),
name: name.into(),
input,
}],
reasoning_content: None,
},
usage: TokenUsage::default(),
stop_reason: Some("tool_use".into()),
meta: None,
}
}
fn done_response() -> LlmResponse {
LlmResponse {
message: Message {
role: "assistant".into(),
content: vec![ContentBlock::Text {
text: "DONE".into(),
}],
reasoning_content: None,
},
usage: TokenUsage::default(),
stop_reason: Some("stop".into()),
meta: None,
}
}
fn test_config(path: &std::path::Path) {
std::fs::write(
path,
"default_model = \"openai/x\"\n\
providers \"openai\" {\n apiKey = \"x\"\n baseUrl = \"http://127.0.0.1:1\"\n \
models \"x\" { name = \"x\" }\n}\n",
)
.unwrap();
}
#[tokio::test]
async fn parallel_opts_register_parallel_task() {
let dir = std::env::temp_dir().join(format!("a3s-ptask-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let cfg = dir.join("config.acl");
test_config(&cfg);
let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string())
.await
.unwrap();
let opts = SessionOptions::new()
.with_max_parallel_tasks(8)
.with_auto_delegation_enabled(true)
.with_auto_parallel_delegation(true)
.with_manual_delegation_enabled(true)
.with_planning_mode(a3s_code_core::PlanningMode::Enabled)
.with_goal_tracking(true)
.with_max_tool_rounds(200);
let session = agent
.session(dir.to_string_lossy().to_string(), Some(opts))
.unwrap();
let names = session.tool_names();
let _ = std::fs::remove_dir_all(&dir);
assert!(
names.contains(&"parallel_task".to_string()) && names.contains(&"task".to_string()),
"parallel_task/task registered under the parallel opts; got {names:?}"
);
}
#[test]
fn format_tool_log_empty_is_none() {
assert!(format_tool_log_records(&[]).is_none());
}
#[test]
fn format_tool_log_renders_header_args_and_output() {
let recs = vec![
ToolCallRecord {
name: "read".into(),
args: Some(serde_json::json!({"file_path": "/x"})),
output: "hello\n".into(),
exit_code: 0,
},
ToolCallRecord {
name: "bash".into(),
args: None,
output: String::new(),
exit_code: 2,
},
];
let out = format_tool_log_records(&recs).unwrap();
assert!(out.contains("#1 · read · ok"), "{out}");
assert!(out.contains("args: {\"file_path\":\"/x\"}"), "{out}");
assert!(
out.contains(" hello"),
"output should be indented: {out}"
);
assert!(out.contains("#2 · bash · exit 2"), "{out}");
}
#[test]
fn deep_research_prompt_directs_research_and_keeps_query() {
let p = deep_research_prompt("rust async runtimes");
assert!(p.contains("rust async runtimes"), "{p}");
let lo = p.to_lowercase();
assert!(lo.contains("deep research"), "{p}");
assert!(lo.contains("web search") && lo.contains("web_fetch"), "{p}");
assert!(lo.contains("source"), "should ask to cite sources: {p}");
}
#[test]
fn deep_research_goal_is_a_research_north_star_with_query() {
let g = deep_research_goal("rust async runtimes");
assert!(g.contains("rust async runtimes"), "{g}");
assert!(g.to_lowercase().contains("research"), "{g}");
}
#[test]
fn scrollbar_blank_when_content_fits() {
let out = append_scrollbar("a\nb\nc", 5, 3, 100);
assert_eq!(out.lines().count(), 3);
for line in out.lines() {
assert!(line.ends_with(' '), "no-overflow gutter blank: {line:?}");
assert!(!line.contains('█') && !line.contains('│'));
}
}
#[test]
fn scrollbar_thumb_tracks_position() {
let view = "r0\nr1\nr2\nr3"; let top = append_scrollbar(view, 4, 40, 0);
assert!(top.lines().next().unwrap().contains('█'), "thumb at top");
let bottom = append_scrollbar(view, 4, 40, 100);
assert!(
bottom.lines().last().unwrap().contains('█'),
"thumb at bottom"
);
assert!(top.lines().all(|l| l.contains('█') || l.contains('│')));
}
#[test]
fn osc52_wraps_base64_in_envelope() {
let s = osc52_copy("hi");
assert!(s.starts_with("\u{1b}]52;c;") && s.ends_with('\u{7}'));
assert!(s.contains("aGk=")); }
#[test]
fn slice_cols_handles_ascii_and_wide() {
assert_eq!(slice_cols("hello", 1, 4), "ell");
assert_eq!(slice_cols("hello", 0, 100), "hello");
assert_eq!(slice_cols("你好", 0, 2), "你");
assert_eq!(slice_cols("你好", 2, 4), "好");
}
#[test]
fn selection_to_text_extracts_span_across_rows() {
let view = " hello world\n second line\n third";
let t = selection_to_text(view, 0, 2, 1, 8);
assert_eq!(t, "hello world\n second");
}
#[test]
fn highlight_selection_touches_only_selected_rows() {
let view = "row zero\nrow one\nrow two";
let out = highlight_selection(view, 1, 0, 1, 7);
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines[0], "row zero"); assert_eq!(lines[2], "row two"); assert!(lines[1].contains("row one")); assert!(lines[1].contains('\u{1b}')); }
#[tokio::test]
async fn web_tools_registered_for_q_research_mode() {
let dir = std::env::temp_dir().join(format!(
"a3s-research-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.acl");
test_config(&cfg);
let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string())
.await
.unwrap();
let session = agent
.session(dir.to_string_lossy().to_string(), None)
.unwrap();
let names = session.tool_names();
let _ = std::fs::remove_dir_all(&dir);
assert!(
names.contains(&"web_search".to_string()) && names.contains(&"web_fetch".to_string()),
"the `?` deep-research mode relies on web_search + web_fetch; got {names:?}"
);
}
#[tokio::test]
async fn claude_session_surface_passes_system_tools_and_skills_to_llm() {
let dir = std::env::temp_dir().join(format!(
"a3s-claude-surface-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.acl");
test_config(&cfg);
std::fs::write(
dir.join("CLAUDE.md"),
"Project rule: claude-session-surface-marker",
)
.unwrap();
let skill_dir = dir.join(".claude/skills/inspect-surface");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: inspect-surface\n\
description: Inspect the Claude session surface\n\
kind: instruction\n\
allowed-tools:\n - Read\n---\n\
Use this skill marker: inspect-surface-skill-marker\n",
)
.unwrap();
let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string())
.await
.unwrap();
let llm = Arc::new(CaptureLlmClient::new(vec![done_response()]));
let opts = SessionOptions::new()
.with_llm_client(llm.clone())
.with_prompt_slots(
SystemPromptSlots::default()
.with_extra(project_instructions(dir.to_str().unwrap()).unwrap()),
)
.with_skill_dirs(agent_skill_dirs(dir.to_str().unwrap()))
.with_manual_delegation_enabled(true)
.with_auto_delegation_enabled(false)
.with_planning_mode(a3s_code_core::PlanningMode::Disabled);
let session = agent
.session(dir.to_string_lossy().to_string(), Some(opts))
.unwrap();
let (mut rx, join) = session
.stream("Use available skills to inspect this project.", None)
.await
.unwrap();
while let Some(event) = rx.recv().await {
if matches!(event, a3s_code_core::AgentEvent::End { .. }) {
break;
}
}
join.await.unwrap();
let turns = llm.turns();
let captured = turns.first().unwrap();
let system = captured.system.as_deref().unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
system.contains("You are A3S Code"),
"core system prompt should reach the LLM"
);
assert!(
system.contains("claude-session-surface-marker"),
"CLAUDE.md project instructions should reach the LLM"
);
assert!(
system.contains("# Skills"),
"skill catalog guidance should reach the LLM system prompt"
);
assert!(
captured.tools.iter().any(|name| name == "read")
&& captured.tools.iter().any(|name| name == "Skill")
&& captured.tools.iter().any(|name| name == "search_skills")
&& captured.tools.iter().any(|name| name == "parallel_task"),
"a3s tools and skill tools should be model-visible; got {:?}",
captured.tools
);
}
#[tokio::test]
async fn claude_can_invoke_skill_and_child_run_receives_skill_prompt() {
let dir = std::env::temp_dir().join(format!(
"a3s-claude-skill-invoke-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.acl");
test_config(&cfg);
let skill_dir = dir.join(".claude/skills/inspect-surface");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: inspect-surface\n\
description: Inspect the Claude session surface\n\
kind: instruction\n\
allowed-tools:\n - Read\n---\n\
Use this skill marker: inspect-surface-skill-marker\n",
)
.unwrap();
let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string())
.await
.unwrap();
let llm = Arc::new(CaptureLlmClient::new(vec![
tool_call_response(
"Skill",
serde_json::json!({
"skill_name": "inspect-surface",
"prompt": "Apply the inspect-surface skill."
}),
),
done_response(),
done_response(),
]));
let opts = SessionOptions::new()
.with_llm_client(llm.clone())
.with_skill_dirs(agent_skill_dirs(dir.to_str().unwrap()))
.with_manual_delegation_enabled(true)
.with_auto_delegation_enabled(false)
.with_permission_policy(
a3s_code_core::permissions::PermissionPolicy::new().allow("Skill(*)"),
)
.with_planning_mode(a3s_code_core::PlanningMode::Disabled)
.with_max_tool_rounds(5);
let session = agent
.session(dir.to_string_lossy().to_string(), Some(opts))
.unwrap();
let result = session
.send("Use the inspect-surface skill.", None)
.await
.unwrap();
let turns = llm.turns();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(result.text.trim(), "DONE");
let system_snippets = turns
.iter()
.enumerate()
.map(|(index, turn)| {
format!(
"#{index}: {}",
turn.system
.as_deref()
.unwrap_or("<none>")
.chars()
.take(220)
.collect::<String>()
)
})
.collect::<Vec<_>>()
.join("\n");
assert!(
turns
.iter()
.any(|turn| turn.system.as_deref().is_some_and(|system| {
system.contains("You are executing the 'inspect-surface' skill")
&& system.contains("inspect-surface-skill-marker")
})),
"Skill tool should start a child LLM run with the skill prompt; turns: {}",
system_snippets
);
}
#[test]
fn workflow_doc_captures_single_task_dispatch() {
let args = serde_json::json!({
"agent": "plan",
"description": "Design the rendering architecture",
"prompt": "Plan a layered renderer."
});
let (doc, label) = workflow_doc_for_tool("task", Some(&args)).unwrap();
assert!(label.contains("delegated task"), "{label}");
assert!(doc.contains("Design the rendering architecture"));
assert!(doc.contains("Agent: `plan`"));
assert!(doc.contains("Plan a layered renderer."));
}
#[test]
fn synthesis_requires_activity_without_followup_text() {
assert!(needs_synthesis(false, false, true, false));
assert!(!needs_synthesis(false, false, true, true));
assert!(!needs_synthesis(false, true, true, false));
assert!(!needs_synthesis(false, false, false, false));
assert!(!needs_synthesis(true, false, true, false));
}
#[test]
fn estimate_tokens_counts_cjk_heavier_than_ascii() {
assert_eq!(estimate_tokens("abcd"), 1); assert_eq!(estimate_tokens("书安操作系统"), 6); assert_eq!(estimate_tokens("hi 书安"), 2); assert_eq!(estimate_tokens(""), 0);
}
#[test]
fn ctx_limit_falls_back_when_undeclared() {
assert_eq!(resolve_ctx_limit(Some(200_000)), 200_000); assert_eq!(resolve_ctx_limit(Some(0)), DEFAULT_CONTEXT_LIMIT); assert_eq!(resolve_ctx_limit(None), DEFAULT_CONTEXT_LIMIT); }
#[test]
fn auto_compact_threshold_scales_to_real_window() {
assert!((auto_compact_threshold_for(128_000) - 0.544).abs() < 0.001);
assert!((auto_compact_threshold_for(200_000) - 0.85).abs() < 0.001);
assert_eq!(auto_compact_threshold_for(1_000_000), 1.0);
assert!((auto_compact_threshold_for(0) - 0.85).abs() < 0.001);
assert!((auto_compact_threshold_for(8_000) - 0.034).abs() < 0.001);
}
#[test]
fn ctx_warn_tier_latches_once_and_rearms_on_drop() {
assert_eq!(ctx_warn_tier(40, 0), (0, None));
assert_eq!(ctx_warn_tier(72, 0), (70, Some(70)));
assert_eq!(ctx_warn_tier(79, 70), (70, None)); assert_eq!(ctx_warn_tier(91, 70), (85, Some(85)));
assert_eq!(ctx_warn_tier(100, 85), (85, None));
assert_eq!(ctx_warn_tier(30, 85), (0, None));
assert_eq!(ctx_warn_tier(72, 0), (70, Some(70)));
assert_eq!(ctx_warn_tier(90, 0), (85, Some(85)));
}
#[test]
fn task_tool_empty_child_output_renders_useful_summary() {
let args = serde_json::json!({
"agent": "plan",
"description": "Plan subsystem boundaries",
"prompt": "Create the plan."
});
let meta = serde_json::json!({
"task_id": "task-abc123",
"session_id": "task-run-task-abc123",
"agent": "plan",
"success": true,
"output_bytes": 0,
"artifact_uri": "a3s://tasks/task-run-task-abc123/runs/task-abc123/output"
});
let output = "Task completed: task-abc123\n\
Agent: plan\n\
Session: task-run-task-abc123\n\
Task ID: task-abc123\n\
Artifact ID: task-output:task-abc123\n\
Artifact URI: a3s://tasks/task-run-task-abc123/runs/task-abc123/output\n\
Output:\n";
let out = render_tool_end("task", 0, output, Some(&meta), Some(&args), 100);
let plain = strip_ansi(&out);
assert!(plain.contains("Explored"));
assert!(plain.contains("Task completed · plan · task-abc123"));
assert!(plain.contains("no child text output"));
assert!(plain.contains("artifact: a3s://tasks/task-run-task-abc123"));
}
#[test]
fn edit_metadata_renders_colored_diff() {
let meta = serde_json::json!({
"file_path": "src/x.rs",
"before": "let a = 1;\nkeep;\n",
"after": "let a = 2;\nkeep;\n",
});
let out = render_tool_end("edit", 0, "ok", Some(&meta), None, 80);
let plain = strip_ansi(&out);
assert!(plain.contains("src/x.rs"), "header has path");
assert!(
plain.contains("+1") && plain.contains("-1"),
"add/del counts"
);
assert!(plain.contains("let a = 2;"), "shows inserted line");
assert!(plain.contains("let a = 1;"), "shows deleted line");
assert!(
plain.contains("keep;"),
"context lines are shown (unified diff)"
);
assert!(plain.contains("Edited src/x.rs"), "edit header with path");
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c2 in chars.by_ref() {
if c2 == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn non_edit_tool_renders_status_line() {
let out = render_tool_end("bash", 0, "hello\nworld", None, None, 80);
assert!(out.contains("Ran") && out.contains("hello"));
assert!(!out.contains('✎'), "no diff marker for non-edit tools");
}
#[test]
fn tool_end_shows_primary_arg_summary() {
let args = serde_json::json!({ "command": "npm test", "timeout": 60 });
let out = render_tool_end("bash", 0, "ok\n", None, Some(&args), 80);
let plain = a3s_tui::style::strip_ansi(&out);
assert!(plain.contains("Ran"), "action verb for bash");
assert!(plain.contains("npm test"), "shows the command argument");
}
#[test]
fn arg_summary_extracts_known_keys() {
assert_eq!(
arg_summary(&serde_json::json!({ "command": "ls -la" })),
Some("ls -la".to_string())
);
assert_eq!(
arg_summary(&serde_json::json!({ "pattern": "TODO" })),
Some("TODO".to_string())
);
assert_eq!(arg_summary(&serde_json::json!({ "unknown": "x" })), None);
}
#[test]
fn reload_is_idle_only_because_it_rebuilds_the_session() {
assert!(IDLE_ONLY.contains(&"/reload"));
}
#[test]
fn fork_is_idle_only_and_listed() {
assert!(IDLE_ONLY.contains(&"/fork"));
assert!(SLASH_COMMANDS.iter().any(|(name, _)| *name == "/fork"));
}
#[test]
fn image_path_detection() {
assert!(is_image_path(std::path::Path::new("a.PNG")));
assert!(is_image_path(std::path::Path::new("x/y.jpeg")));
assert!(!is_image_path(std::path::Path::new("main.rs")));
assert!(!is_image_path(std::path::Path::new("noext")));
}
#[test]
fn half_block_render_packs_two_rows_and_colors() {
let img = ::image::DynamicImage::ImageRgba8(::image::RgbaImage::from_pixel(
4,
6,
::image::Rgba([10, 20, 30, 255]),
));
let lines = render_image_blocks(&img, 80, 40);
assert_eq!(lines.len(), 3, "6px / 2 = 3 rows");
assert!(lines[0].contains('▀'), "uses upper half-block");
assert!(lines[0].contains("\x1b["), "carries ANSI color");
}
#[test]
fn half_block_render_fits_within_bounds() {
let img = ::image::DynamicImage::ImageRgba8(::image::RgbaImage::new(400, 400));
let lines = render_image_blocks(&img, 20, 10);
assert!(lines.len() <= 10, "never exceeds max_rows");
}
#[test]
fn clipboard_helper_cleans_up_on_no_image() {
let dest = std::env::temp_dir().join("a3s-test-noimg.png");
let _ = std::fs::remove_file(&dest);
let ok = clipboard_image_to(&dest);
if !ok {
assert!(!dest.exists(), "failed paste leaves no file");
} else {
let _ = std::fs::remove_file(&dest);
}
}
#[test]
fn char_byte_handles_ascii_and_cjk() {
assert_eq!(char_byte("hello", 0), 0);
assert_eq!(char_byte("hello", 3), 3);
assert_eq!(char_byte("hello", 5), 5); assert_eq!(char_byte("你好", 1), 3);
assert_eq!(char_byte("你好", 2), 6);
}
#[test]
fn char_byte_supports_inplace_edits() {
let mut s = String::from("ab");
let b = char_byte(&s, 1);
s.insert(b, '中');
assert_eq!(s, "a中b");
}
#[test]
fn starter_config_template_parses() {
let p = std::env::temp_dir().join("a3s-template-test.acl");
std::fs::write(&p, config_template()).unwrap();
let cfg = a3s_code_core::config::CodeConfig::from_file(&p)
.expect("starter template must parse as valid ACL");
let models: Vec<_> = cfg.list_models().into_iter().collect();
assert!(!models.is_empty(), "template defines at least one model");
let _ = std::fs::remove_file(&p);
}
#[test]
fn counts_skill_dirs_and_flat_md() {
let base = std::env::temp_dir().join("a3s-skillcount-test");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("myskill")).unwrap();
std::fs::write(base.join("myskill/SKILL.md"), "# skill").unwrap();
std::fs::write(base.join("flat.md"), "# flat skill").unwrap();
std::fs::write(base.join("notes.txt"), "ignored").unwrap();
assert_eq!(count_skill_files(std::slice::from_ref(&base)), 2);
let _ = std::fs::remove_dir_all(&base);
}
}