Port /export command from TypeScript TUI.

2185306d8058 · AtlantisPleb · · parent a9cbb4e8196b

Port /export command from TypeScript TUI.

- Adds `crates/coder-lite/src/export.rs` that writes an ATIF v1.7 JSON file
  to `~/.openagents/exports`, copies the path to the system clipboard, and
  returns step count.
- `Entry` gains `at` (epoch ms) and `tool: Option<ToolCall>` for ATIF export.
- `Control::Tool` now carries the tool call name/arguments/title; a new
  `Control::ToolTitle` updates the one-line header from the ACP child.
- `/export` in the composer writes the current transcript and shows the
  resulting file path and clipboard status.
- Supports macOS pbcopy, Windows clip, and Linux wl-copy/xclip/xsel.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added crates/coder-lite/src/export.rs
  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/lib.rs
  • modified crates/coder-lite/src/runtime.rs
  • modified crates/coder-lite/src/tui.rs
  • modified crates/coder-lite/tests/markdown.rs
  • modified crates/coder-lite/tests/tool_box.rs

Diff

7 files changed, +403 -40

crates/coder-lite/src/export.rs added +259

@@ -0,0 +1,259 @@

1
//! ATIF session export and clipboard support.
2
3
use crate::tui::{Entry, Role, ToolCall};
4
use serde_json::{json, Value};
5
use std::fs;
6
use std::io::Write;
7
use std::path::PathBuf;
8
use std::process::{Command, Stdio};
9
use std::time::{SystemTime, UNIX_EPOCH};
10
11
const SCHEMA_VERSION: &str = "ATIF-v1.7";
12
const AGENT_NAME: &str = "openagents-coder";
13
const INTERFACE_COMMANDS: &[&str] = &["/export", "/system", "/skills"];
14
15
pub struct ExportedTrajectory {
16
    pub path: String,
17
    pub copied: bool,
18
    pub steps: usize,
19
}
20
21
fn now_ms() -> u64 {
22
    SystemTime::now()
23
        .duration_since(UNIX_EPOCH)
24
        .unwrap_or_default()
25
        .as_millis() as u64
26
}
27
28
fn home_dir() -> Option<String> {
29
    std::env::var("HOME")
30
        .ok()
31
        .or_else(|| std::env::var("USERPROFILE").ok())
32
}
33
34
fn run_with_input(command: &str, args: &[&str], input: &str) -> Option<()> {
35
    let mut child = Command::new(command)
36
        .args(args)
37
        .stdin(Stdio::piped())
38
        .spawn()
39
        .ok()?;
40
    child
41
        .stdin
42
        .take()
43
        .and_then(|mut s| s.write_all(input.as_bytes()).ok());
44
    let status = child.wait().ok()?;
45
    if status.success() {
46
        Some(())
47
    } else {
48
        None
49
    }
50
}
51
52
fn copy_to_clipboard(text: &str) -> bool {
53
    let candidates: &[(&str, &[&str])] = if cfg!(target_os = "macos") {
54
        &[("pbcopy", &[])]
55
    } else if cfg!(target_os = "windows") {
56
        &[("clip", &[])]
57
    } else {
58
        &[
59
            ("wl-copy", &[]),
60
            ("xclip", &["-selection", "clipboard"]),
61
            ("xsel", &["--clipboard", "--input"]),
62
        ]
63
    };
64
    for (cmd, args) in candidates {
65
        if run_with_input(cmd, args, text).is_some() {
66
            return true;
67
        }
68
    }
69
    false
70
}
71
72
fn python3_or_python() -> Option<String> {
73
    if Command::new("python3").arg("--version").output().is_ok() {
74
        Some("python3".to_string())
75
    } else if Command::new("python").arg("--version").output().is_ok() {
76
        Some("python".to_string())
77
    } else {
78
        None
79
    }
80
}
81
82
fn iso_for_ms(at: u64) -> String {
83
    if let Some(py) = python3_or_python() {
84
        let script = format!(
85
            "import datetime; \
86
             s=datetime.datetime(1970,1,1)+datetime.timedelta(milliseconds={}); \
87
             print(s.strftime('%Y-%m-%dT%H:%M:%S'))",
88
            at
89
        );
90
        if let Ok(output) = Command::new(&py).args(["-c", &script]).output() {
91
            let base = String::from_utf8_lossy(&output.stdout).trim().to_string();
92
            if !base.is_empty() {
93
                let ms = at % 1000;
94
                return format!("{}.{:03}Z", base, ms);
95
            }
96
        }
97
    }
98
    // Fallback to a seconds-only timestamp if python is not available.
99
    let seconds = at / 1000;
100
    let ms = at % 1000;
101
    format!("1970-01-01T00:00:{:02}.{:03}Z", seconds % 60, ms)
102
}
103
104
fn now_iso() -> String {
105
    iso_for_ms(now_ms())
106
}
107
108
pub fn git_info() -> Option<(String, String)> {
109
    let repo = Command::new("git")
110
        .args(["rev-parse", "--show-toplevel"])
111
        .output()
112
        .ok()
113
        .and_then(|o| {
114
            let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
115
            if s.is_empty() { None } else { Some(s) }
116
        })?;
117
118
    let branch = Command::new("git")
119
        .args(["branch", "--show-current"])
120
        .output()
121
        .ok()
122
        .and_then(|o| {
123
            let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
124
            if s.is_empty() { None } else { Some(s) }
125
        })
126
        .unwrap_or_else(|| "unknown".to_string());
127
128
    Some((repo, branch))
129
}
130
131
fn is_interface_command(text: &str) -> bool {
132
    let t = text.trim();
133
    INTERFACE_COMMANDS.iter().any(|cmd| t == *cmd)
134
}
135
136
fn step_of(
137
    entry: &Entry,
138
    model: &str,
139
    tool: Option<&ToolCall>,
140
) -> Option<Value> {
141
    let timestamp = iso_for_ms(entry.at);
142
    match entry.role {
143
        Role::You if !is_interface_command(&entry.text) => Some(json!({
144
            "step_id": 0,
145
            "timestamp": timestamp,
146
            "source": "user",
147
            "message": entry.text,
148
        })),
149
        Role::Assistant if !entry.text.is_empty() => Some(json!({
150
            "step_id": 0,
151
            "timestamp": timestamp,
152
            "source": "agent",
153
            "message": entry.text,
154
            "model_name": model,
155
        })),
156
        Role::Tool => {
157
            let tool = tool?;
158
            let content = entry.output.as_deref().unwrap_or("");
159
            Some(json!({
160
                "step_id": 0,
161
                "timestamp": timestamp,
162
                "source": "agent",
163
                "message": "",
164
                "model_name": model,
165
                "tool_calls": [{
166
                    "tool_call_id": tool.call_id,
167
                    "function_name": tool.function_name,
168
                    "arguments": tool.arguments,
169
                }],
170
                "observation": {
171
                    "results": [{
172
                        "source_call_id": tool.call_id,
173
                        "content": content,
174
                    }]
175
                }
176
            }))
177
        }
178
        _ => None,
179
    }
180
}
181
182
fn file_name(repository: &str, at_iso: &str) -> String {
183
    let safe = repository
184
        .split('/')
185
        .last()
186
        .unwrap_or(repository)
187
        .replace(|c: char| !c.is_alphanumeric() && c != '.' && c != '-' && c != '_', "-");
188
    let stamp = at_iso.replace(':', "-").replace('.', "-");
189
    format!("{}-{}-atif.json", stamp, safe)
190
}
191
192
pub fn export_trajectory(
193
    entries: &[Entry],
194
    model: &str,
195
    repo: &str,
196
    branch: &str,
197
) -> ExportedTrajectory {
198
    let at_iso = now_iso();
199
    let mut steps = Vec::new();
200
    let mut notices = Vec::new();
201
202
    for (i, entry) in entries.iter().enumerate() {
203
        if let Some(notice) = match entry.role {
204
            Role::Notice => Some(json!({
205
                "timestamp": iso_for_ms(entry.at),
206
                "text": entry.text,
207
            })),
208
            _ => None,
209
        } {
210
            notices.push(notice);
211
        }
212
213
        if let Some(mut step) = step_of(entry, model, entry.tool.as_ref()) {
214
            if let Some(obj) = step.as_object_mut() {
215
                obj.insert("step_id".to_string(), json!(i + 1));
216
            }
217
            steps.push(step);
218
        }
219
    }
220
221
    let document = json!({
222
        "schema_version": SCHEMA_VERSION,
223
        "session_id": format!("{}-{}", repo, at_iso),
224
        "trajectory_id": format!("{}-{}", repo, at_iso),
225
        "agent": {
226
            "name": AGENT_NAME,
227
            "version": env!("CARGO_PKG_VERSION"),
228
            "model_name": model,
229
        },
230
        "steps": steps,
231
        "final_metrics": {
232
            "total_steps": steps.len(),
233
        },
234
        "extra": {
235
            "exporter": "openagents.coder.atif_export.v1",
236
            "exported_at": at_iso,
237
            "repository": repo,
238
            "branch": branch,
239
            "notices": notices,
240
        }
241
    });
242
243
    let directory = home_dir()
244
        .map(|h| PathBuf::from(h).join(".openagents").join("exports"))
245
        .unwrap_or_else(|| PathBuf::from(".openagents").join("exports"));
246
    fs::create_dir_all(&directory).unwrap_or_default();
247
248
    let path = directory.join(file_name(repo, &at_iso));
249
    let path_str = path.to_string_lossy().to_string();
250
    fs::write(&path, format!("{}\n", serde_json::to_string_pretty(&document).unwrap_or_default()))
251
        .ok();
252
253
    let copied = copy_to_clipboard(&path_str);
254
    ExportedTrajectory {
255
        path: path_str,
256
        copied,
257
        steps: steps.len(),
258
    }
259
}
crates/coder-lite/src/interactive.rs modified +99 -19

@@ -5,8 +5,10 @@

5 5
//! `modifiers` so control chords do not fall through to plain character input.
6 6
7 7
use crate::acp;
8
use crate::export::{export_trajectory, git_info};
8 9
use crate::runtime::{CoderRuntimeSession, Control};
9
use crate::tui::{CoderUi, Entry, Role};
10
use crate::tui::{now_ms, CoderUi, Entry, Role, ToolCall};
11
use std::env;
10 12
use std::sync::mpsc;
11 13
use crossterm::{
12 14
    event::{

@@ -55,6 +57,8 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

55 57
                role: Role::Notice,
56 58
                text: format!("found ACP agents: {}", list),
57 59
                output: None,
60
                tool: None,
61
                at: now_ms(),
58 62
            });
59 63
            ui.agents = agents;
60 64
        }

@@ -63,10 +67,17 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

63 67
                role: Role::Notice,
64 68
                text: "found ACP agents: none".to_string(),
65 69
                output: None,
70
                tool: None,
71
                at: now_ms(),
66 72
            });
67 73
        }
68 74
    }
69 75
76
    let (repo, branch) = git_info().unwrap_or(("unknown".to_string(), "unknown".to_string()));
77
    ui.repo = repo;
78
    ui.branch = branch;
79
    ui.model = env::var("OPENAGENTS_MODEL").unwrap_or_default();
80
70 81
    loop {
71 82
        while let Ok(control) = rx.try_recv() {
72 83
            match control {

@@ -82,20 +93,59 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

82 93
                    }
83 94
                }
84 95
                Control::Done => ui.loading = false,
85
                Control::Tool { agent, title } => {
96
                Control::Tool {
97
                    function_name,
98
                    arguments,
99
                    title,
100
                } => {
101
                    let parsed = serde_json::from_str(&arguments).unwrap_or_else(|_| {
102
                        serde_json::json!({ "unparsed_arguments": arguments })
103
                    });
104
                    let call_id = format!("call-{}", ui.entries.len());
105
                    let agent = parsed
106
                        .get("agent")
107
                        .and_then(|v| v.as_str())
108
                        .unwrap_or("unknown")
109
                        .to_string();
86 110
                    ui.entries.push(Entry {
87 111
                        role: Role::Tool,
88 112
                        text: format!("delegate {}: {}", agent, title),
89 113
                        output: Some(String::new()),
114
                        tool: Some(ToolCall {
115
                            call_id,
116
                            function_name,
117
                            arguments: parsed,
118
                            output: None,
119
                            error: None,
120
                        }),
121
                        at: now_ms(),
90 122
                    });
91 123
                    ui.scroll_override = None;
92 124
                }
125
                Control::ToolTitle(title) => {
126
                    if let Some(last) = ui.entries.last_mut() {
127
                        if last.role == Role::Tool {
128
                            let agent = last
129
                                .text
130
                                .split_whitespace()
131
                                .nth(1)
132
                                .and_then(|s| s.strip_suffix(':'))
133
                                .unwrap_or("unknown")
134
                                .to_string();
135
                            last.text = format!("delegate {}: {}", agent, title);
136
                        }
137
                    }
138
                    ui.scroll_override = None;
139
                }
93 140
                Control::ToolText(chunk) => {
94 141
                    if let Some(last) = ui.entries.last_mut() {
95 142
                        if last.role == Role::Tool {
96 143
                            last.output
97 144
                                .get_or_insert_with(String::new)
98 145
                                .push_str(&chunk);
146
                            if let Some(ref mut tool) = last.tool {
147
                                tool.output = last.output.clone();
148
                            }
99 149
                        }
100 150
                    }
101 151
                    ui.scroll_override = None;

@@ -133,26 +183,56 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

133 183
                            ui.composer.push('\n');
134 184
                        } else if !ui.composer.trim().is_empty() {
135 185
                            let prompt = ui.composer.clone();
136
                            ui.entries.push(Entry {
137
                                role: Role::You,
138
                                text: prompt.clone(),
139
                                output: None,
140
                            });
141
                            ui.entries.push(Entry {
142
                                role: Role::Assistant,
143
                                text: String::new(),
144
                                output: None,
145
                            });
146 186
                            ui.composer.clear();
147 187
                            ui.scroll_override = None;
148
                            ui.loading = true;
149 188
150
                            let mut session = CoderRuntimeSession::new();
151
                            session.agents = ui.agents.clone();
152
                            let tx = tx.clone();
153
                            tokio::spawn(async move {
154
                                let _ = session.execute_turn(&prompt, tx).await;
155
                            });
189
                            if prompt.trim() == "/export" {
190
                                ui.entries.push(Entry {
191
                                    role: Role::You,
192
                                    text: prompt,
193
                                    output: None,
194
                                    tool: None,
195
                                    at: now_ms(),
196
                                });
197
                                let model = ui.model.clone();
198
                                let result =
199
                                    export_trajectory(&ui.entries, &model, &ui.repo, &ui.branch);
200
                                ui.entries.push(Entry {
201
                                    role: Role::Notice,
202
                                    text: format!(
203
                                        "exported {} steps to {} (copied: {})",
204
                                        result.steps,
205
                                        result.path,
206
                                        result.copied
207
                                    ),
208
                                    output: None,
209
                                    tool: None,
210
                                    at: now_ms(),
211
                                });
212
                            } else {
213
                                ui.entries.push(Entry {
214
                                    role: Role::You,
215
                                    text: prompt.clone(),
216
                                    output: None,
217
                                    tool: None,
218
                                    at: now_ms(),
219
                                });
220
                                ui.entries.push(Entry {
221
                                    role: Role::Assistant,
222
                                    text: String::new(),
223
                                    output: None,
224
                                    tool: None,
225
                                    at: now_ms(),
226
                                });
227
                                ui.loading = true;
228
229
                                let mut session = CoderRuntimeSession::new();
230
                                session.agents = ui.agents.clone();
231
                                let tx = tx.clone();
232
                                tokio::spawn(async move {
233
                                    let _ = session.execute_turn(&prompt, tx).await;
234
                                });
235
                            }
156 236
                        }
157 237
                    }
158 238
                    KeyEvent {
crates/coder-lite/src/lib.rs modified +1

@@ -2,6 +2,7 @@

2 2
3 3
pub mod acp;
4 4
pub mod acp_harness;
5
pub mod export;
5 6
pub mod interactive;
6 7
pub mod runtime;
7 8
pub mod tui;
crates/coder-lite/src/runtime.rs modified +18 -21

@@ -16,7 +16,12 @@ const SYSTEM_INSTRUCTIONS: &str = "You are OpenAgents Coder. Do not say you are

16 16
pub enum Control {
17 17
    Chunk(String),
18 18
    Done,
19
    Tool { agent: String, title: String },
19
    Tool {
20
        function_name: String,
21
        arguments: String,
22
        title: String,
23
    },
24
    ToolTitle(String),
20 25
    ToolText(String),
21 26
    ToolDone,
22 27
}

@@ -76,7 +81,7 @@ impl CoderRuntimeSession {

76 81
            };
77 82
78 83
            let mut collected = String::new();
79
            let mut pending_tool: Option<(String, String, String)> = None;
84
            let mut pending_tool: Option<(String, String, String, String)> = None;
80 85
81 86
            while let Some(event) = stream.next().await {
82 87
                match event {

@@ -108,7 +113,7 @@ impl CoderRuntimeSession {

108 113
                            .and_then(|v| v.as_str())
109 114
                            .unwrap_or("")
110 115
                            .to_string();
111
                        pending_tool = Some((call_id, agent, task));
116
                        pending_tool = Some((call_id, agent, task, arguments));
112 117
                    }
113 118
                    Ok(StreamingEvent::Error { error, .. }) => {
114 119
                        let msg = format!("[error: {:?}]", error);

@@ -123,8 +128,14 @@ impl CoderRuntimeSession {

123 128
                }
124 129
            }
125 130
126
            if let Some((call_id, agent_id, task)) = pending_tool.take() {
131
            if let Some((call_id, agent_id, task, raw_args)) = pending_tool.take() {
127 132
                if let Some(agent) = self.agents.iter().find(|a| a.id == agent_id).cloned() {
133
                    let _ = tx.send(Control::Tool {
134
                        function_name: "delegate".to_string(),
135
                        arguments: raw_args,
136
                        title: task.clone(),
137
                    });
138
128 139
                    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
129 140
                    let mut header_sent = false;
130 141
                    let result = {

@@ -137,10 +148,7 @@ impl CoderRuntimeSession {

137 148
                            match event {
138 149
                                AcpEvent::Tool { title, .. } => {
139 150
                                    header_sent = true;
140
                                    let _ = tx.send(Control::Tool {
141
                                        agent: agent_id.clone(),
142
                                        title,
143
                                    });
151
                                    let _ = tx.send(Control::ToolTitle(title));
144 152
                                }
145 153
                                AcpEvent::Text { chunk } => {
146 154
                                    let _ = tx.send(Control::ToolText(chunk));

@@ -153,21 +161,10 @@ impl CoderRuntimeSession {

153 161
154 162
                    match &result {
155 163
                        Ok(_) if !header_sent => {
156
                            let _ = tx.send(Control::Tool {
157
                                agent: agent_id.clone(),
158
                                title: "completed".to_string(),
159
                            });
160
                        }
161
                        Err(_) if !header_sent => {
162
                            let _ = tx.send(Control::Tool {
163
                                agent: agent_id.clone(),
164
                                title: "error".to_string(),
165
                            });
166
                            let _ = tx.send(Control::ToolText(
167
                                result.as_ref().err().unwrap().to_string(),
168
                            ));
164
                            let _ = tx.send(Control::ToolTitle("completed".to_string()));
169 165
                        }
170 166
                        Err(_) => {
167
                            let _ = tx.send(Control::ToolTitle("error".to_string()));
171 168
                            let _ = tx.send(Control::ToolText(
172 169
                                result.as_ref().err().unwrap().to_string(),
173 170
                            ));
crates/coder-lite/src/tui.rs modified +22

@@ -9,6 +9,8 @@ use ratatui::{

9 9
};
10 10
use ratatui_markdown::markdown::MarkdownRenderer;
11 11
use ratatui_markdown::theme::{CodeColors, Generation, RichTextTheme};
12
use serde_json::Value;
13
use std::time::{SystemTime, UNIX_EPOCH};
12 14
13 15
const TEXT_COLOR: Color = Color::Rgb(255, 176, 0);
14 16
const BACKGROUND_COLOR: Color = Color::Rgb(8, 6, 0);

@@ -97,12 +99,32 @@ pub enum Role {

97 99
    Notice,
98 100
}
99 101
102
/// One tool call captured for ATIF export.
103
#[derive(Debug, Clone)]
104
pub struct ToolCall {
105
    pub call_id: String,
106
    pub function_name: String,
107
    pub arguments: Value,
108
    pub output: Option<String>,
109
    pub error: Option<String>,
110
}
111
100 112
#[derive(Debug, Clone)]
101 113
pub struct Entry {
102 114
    pub role: Role,
103 115
    pub text: String,
104 116
    /// Tool output text, rendered as a ~5-line box split by newlines.
105 117
    pub output: Option<String>,
118
    pub tool: Option<ToolCall>,
119
    pub at: u64,
120
}
121
122
/// Current time as epoch milliseconds.
123
pub fn now_ms() -> u64 {
124
    SystemTime::now()
125
        .duration_since(UNIX_EPOCH)
126
        .unwrap_or_default()
127
        .as_millis() as u64
106 128
}
107 129
108 130
#[derive(Debug)]
crates/coder-lite/tests/markdown.rs modified +2

@@ -9,6 +9,8 @@ fn renders_markdown_bold_and_italic() {

9 9
        role: Role::Assistant,
10 10
        text: "**bold** and *italic*".to_string(),
11 11
        output: None,
12
        tool: None,
13
        at: 0,
12 14
    });
13 15
14 16
    let backend = TestBackend::new(80, 24);
crates/coder-lite/tests/tool_box.rs modified +2

@@ -9,6 +9,8 @@ fn renders_delegate_tool_call_and_five_line_box() {

9 9
        role: Role::Tool,
10 10
        text: "delegate devin: Read src/main.rs".to_string(),
11 11
        output: Some("Reading file...\nFound main()\nDone".to_string()),
12
        tool: None,
13
        at: 0,
12 14
    });
13 15
14 16
    let backend = TestBackend::new(80, 24);

This page updates live while a promote is in flight · changelog