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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "a3s"
version = "0.5.13"
version = "0.5.14"
edition = "2021"
description = "a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI"
license = "MIT"
Expand Down
18 changes: 14 additions & 4 deletions skills/a3s-os-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,30 @@ signed-in user may access.
- Prefer read/`GET`-style operations for discovery; write operations (create /
update / delete) run with the user's real platform permissions — confirm intent
before mutating platform state.
- **Always report the call trace.** Every response envelope carries a `requestId`
and a `timestamp`. After summarizing the result, output them on their own line so
the call is traceable, exactly: `↳ requestId <requestId> · <timestamp>`. Keep
`.requestId` and `.timestamp` in any `jq` projection (don't narrow them away).
- **Offer the `view` as an inline link — never auto-open.** Some `execute`
responses include a `view` object — `{ "url": "…?embed=1", "width": N, "height":
N }` — a focused console page sized for a popup. Two things must happen:
1. **Keep `.view` in your command's JSON stdout** so the host can capture it —
do **not** narrow it away with `jq`; emit the full response or keep it in
your projection (e.g. `... | jq '{ data, view }'`). Never fabricate or drop
a returned `view`.
2. **End your reply with the link on its own line, exactly:** `🔗 打开渐进式UI`
— the host turns any reply line containing `打开渐进式UI` into a one-click
your projection (e.g. `... | jq '{ data, view, requestId, timestamp }'`).
Never fabricate or drop a returned `view`.
2. **End your reply with the link on its own line, exactly:** `🔗 查看视图`
— the host turns any reply line containing `查看视图` into a one-click
trigger that opens the view in the authenticated **渐进式UI** popup (the
user's current OS login is injected — no re-login). RemoteUI is
**user-triggered**: the popup is NOT opened automatically; the user clicks
that link (or runs `/view`). Do **not** print the raw URL yourself — the link
line is the affordance.

So a typical reply ends with two lines:
```
↳ requestId 52178323-b614-42e4-af60-4b0b91ad8355 · 2026-07-01T01:21:07.905Z
🔗 查看视图
```
- The `ui` field (`protocol: "agent-ui"`) is a host-rendered remote component —
note that it exists if present, but don't try to render it yourself.

Expand Down
19 changes: 12 additions & 7 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,11 @@ Go broad→narrow: `list` (modules) → `describe`/`search` for the one operatio
Pipe responses through `jq` to extract ONLY the fields you need (e.g. \
`| jq -r '.data.modules[].name'`) so output stays a few lines; summarize the result for the user \
in a few lines and do NOT paste the whole raw JSON back. \
Every response envelope has a `requestId` and `timestamp`; after your summary, ALWAYS output them on \
their own line, exactly `↳ requestId <requestId> · <timestamp>`, so the call is traceable (keep \
`.requestId`/`.timestamp` in any jq projection). \
If a response contains a `view` object (a console page sized for a popup), keep `.view` in your \
JSON output and END your reply with the link on its own line, exactly `🔗 打开渐进式UI` — the host \
JSON output and END your reply with the link on its own line, exactly `🔗 查看视图` — the host \
turns it into a one-click trigger that opens the authenticated 渐进式UI popup (the user's OS login \
is injected, no re-login). Do not print the raw URL. The `a3s-os-capabilities` skill has full examples."
)
Expand Down Expand Up @@ -523,7 +526,7 @@ fn osc52_copy(text: &str) -> String {
/// host recognises a mouse click on any reply line containing it and opens the
/// remembered view (`/view` does the same). The link lives in the message text —
/// the host renders no button of its own.
const VIEW_BUTTON_MARKER: &str = "打开渐进式UI";
const VIEW_BUTTON_MARKER: &str = "查看视图";

/// Put `text` on the system clipboard: OSC 52 (portable, survives SSH on
/// supporting terminals) plus the native tool where we have one (macOS pbcopy).
Expand Down Expand Up @@ -1219,7 +1222,7 @@ struct App {
/// gateway is unavailable/unconfigured.
os_gateway_models: Option<Vec<String>>,
/// Last 书安OS view seen in a tool result. RemoteUI is user-triggered: `/view`
/// or clicking the agent's inline "打开渐进式UI" link opens it in the native
/// or clicking the agent's inline "查看视图" link opens it in the native
/// a3s-webview window — it is never auto-opened.
last_view: Option<remote_ui::ViewSpec>,
/// Current model effort (index into EFFORT_LEVELS).
Expand Down Expand Up @@ -1769,7 +1772,7 @@ impl Model for App {
if let Some(s) = self.selection {
if s.is_empty() {
// A plain click: open the OS view if it landed on
// the agent's inline "打开渐进式UI" link; else just clear.
// the agent's inline "查看视图" link; else just clear.
let view = self.viewport.view();
let clicked = a3s_tui::style::strip_ansi(
view.split('\n')
Expand Down Expand Up @@ -3348,7 +3351,7 @@ impl App {
// open it now in the native a3s-webview window (auth via $A3S_OS_TOKEN).
if let Some(spec) = remote_ui::find_view_url(&output) {
// RemoteUI is user-triggered — never auto-open. Remember the
// view; the agent offers it via an inline "打开渐进式UI" link
// view; the agent offers it via an inline "查看视图" link
// (clicking that line, or `/view`, opens the popup).
self.last_view = Some(spec);
}
Expand Down Expand Up @@ -5069,8 +5072,10 @@ mod tests {
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);
assert!(out.contains("Ran"), "action verb for bash");
assert!(out.contains("npm test"), "shows the command argument");
// Bash args are token-colored (program/flags/args), so check visible text.
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]
Expand Down
45 changes: 44 additions & 1 deletion src/tui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ pub(crate) fn render_tool_end(
.bold()
.render("•");
let arg = args.and_then(arg_summary).unwrap_or_default();
// Bash commands get Codex-style token coloring (program vs flags vs args);
// everything else keeps the muted single-color summary.
let arg_styled = if matches!(name, "bash" | "shell" | "run" | "exec") {
highlight_shell(&arg)
} else {
Style::new().fg(TN_GRAY).render(&arg)
};
let header = if arg.is_empty() {
format!(
"{margin}{dot} {}",
Expand All @@ -49,7 +56,7 @@ pub(crate) fn render_tool_end(
format!(
"{margin}{dot} {} {}",
Style::new().bold().render(tool_verb(name)),
Style::new().fg(TN_GRAY).render(&arg)
arg_styled
)
};

Expand Down Expand Up @@ -291,6 +298,27 @@ pub(crate) fn tool_verb(name: &str) -> &str {
/// Claude-Code-style tool label: `Tool(arg)`, e.g. "Bash(npm test)",
/// "Read(src/main.rs)", "Update(lib.rs)". Used for the live-running indicator
/// and the approval prompt.
/// Codex-style coloring for a shell command in a tool header: the program name
/// stands out (bold cyan), flags are distinct (yellow), and positional args are
/// muted (gray) so the line is scannable at a glance.
pub(crate) fn highlight_shell(cmd: &str) -> String {
let mut out = String::new();
for (i, tok) in cmd.split_whitespace().enumerate() {
if i > 0 {
out.push(' ');
}
let styled = if i == 0 {
Style::new().fg(TN_CYAN).bold().render(tok)
} else if tok.starts_with('-') {
Style::new().fg(TN_YELLOW).render(tok)
} else {
Style::new().fg(TN_GRAY).render(tok)
};
out.push_str(&styled);
}
out
}

pub(crate) fn tool_label(name: &str, args: Option<&serde_json::Value>) -> String {
let target = args.and_then(arg_summary).unwrap_or_default();
let display = match name {
Expand Down Expand Up @@ -483,3 +511,18 @@ pub(crate) fn render_diff(path: &str, before: &str, after: &str, width: usize) -
}
out
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn highlight_shell_colors_tokens_and_preserves_text() {
let s = highlight_shell("curl -s -X POST http://x");
// Styling was applied (escape sequences present)...
assert!(s.contains('\u{1b}'));
// ...but the visible text is unchanged (single-spaced tokens).
assert_eq!(a3s_tui::style::strip_ansi(&s), "curl -s -X POST http://x");
assert_eq!(highlight_shell(""), "");
}
}
Loading