Skip to content
Closed
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
30 changes: 10 additions & 20 deletions rust/crates/caos-cli/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,6 @@ so it never leaves the conversation pane.
| `Ctrl+Y` | Release mouse capture and freeze redraws for native selection |
| `/checkout <gitlink> [directory]` | Check out this commit, reusing its local directory when omitted |
| `Ctrl+O` | Browse conversation files and source-tree diffs |
| `/pr <gitlink> <base-branch> [remote-URL]` | Preview one PR; Enter confirms |
| `/publish-branch <gitlink> [remote-URL]` | Preview and push this snapshot without creating a PR |
| `Ctrl+R` | Reload completed conversation history |
| `Ctrl+C` | Clear a non-empty prompt; exit when the prompt is empty |

Expand All @@ -173,10 +171,7 @@ Completed user and agent turns show branchable hashes in the transcript. Enter
TUI. Enter `/title <new title>` to change the
shared title without changing the conversation ID (the metadata update advances its conversation head). Enter `/model <name>` to select the client-wide model
for later turns; known model names type ahead. `/model default` restores the
client default. Enter `/update-tree <gitlink> <message>` to send an ordinary
user turn whose commit also folds in edits in that gitlink's remembered checkout — the
intended companion to `/checkout <gitlink> [directory]` (check out the head, edit files, then
`/update-tree <gitlink> <message>` with the text you want in that turn). Activity entries
client default. Activity entries
show the durable hashes of internal harness steps for inspection; those step
trees contain harness metadata and are not branch points.

Expand Down Expand Up @@ -318,20 +313,15 @@ conversation history. Opening and running conversations never overwrite a
checkout. `/checkout <gitlink> [directory]` uses an explicit destination or
reuses that gitlink's remembered local directory. The destination must be a clean Git checkout
or an empty/new directory. The client imports the code objects and detaches HEAD
at the named commit. `/update-tree <gitlink> <message>` commits local edits in
that gitlink's remembered checkout and imports their
closure into the client before submission. These commands never replace the
internal harness.

Publication preserves source tree history, uses leased branch updates, and
checks conflict cleanup before preview and again before pushing. Resolve a
nonempty `.caos/conflicts` ledger by fixing each path and clearing its entries.
Saving an edited source tree removes an empty ledger and prunes its empty
`.caos` directory. Any remaining `.caos` entry blocks publication; publishing
never rewrites the selected commit. It leaves the local
checkout and index unchanged. Credentials remain in the local secret store;
the launcher reuses an existing checkout store or its own persistent store under
the data directory.
at the named commit. Commit local edits with Git, then use
`/import imports/local-edit /absolute/path/to/checkout` to attach that commit at
an unused conversation path. Ask the agent to integrate the imported source.
These commands never replace the internal harness.

The agent publishes branches and manages PRs. See
[GitHub interactions](../../../design/agent-github.md) for the workflow.
Credentials are supplied through the local secret store; the launcher uses the
checkout's store or its persistent store under the data directory.


Over SSH, clipboard copying uses a terminal escape sequence. “Copy requested”
Expand Down
189 changes: 20 additions & 169 deletions rust/crates/caos-cli/src/bin/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use ratatui_crossterm::crossterm::event::{
};

use super::args::Args;
use super::source_tree::{commit_working_tree, load_conversation_source_tree};
use super::source_tree::load_conversation_source_tree;
use super::CopyOutcome;

#[path = "filesystem.rs"]
Expand Down Expand Up @@ -850,7 +850,6 @@ enum AppAction {
Commands,
Reference,
Title,
UpdateTree,
Import,
BrowseFiles,
NewConversation,
Expand All @@ -862,12 +861,6 @@ enum AppAction {
SelectionLock,
}

impl AppAction {
fn submits_message(self) -> bool {
matches!(self, Self::UpdateTree)
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Command {
name: &'static str,
Expand All @@ -891,7 +884,7 @@ const MODEL_OPTIONS: [&str; 8] = [
"claude-opus-4-6",
];

const COMMANDS: [Command; 11] = [
const COMMANDS: [Command; 10] = [
Command {
name: "/from",
usage: "/from <commit>",
Expand All @@ -913,13 +906,6 @@ const COMMANDS: [Command; 11] = [
action: AppAction::Title,
takes_argument: true,
},
Command {
name: "/update-tree",
usage: "/update-tree <conversation/gitlink> <message>",
description: "submit local edits",
action: AppAction::UpdateTree,
takes_argument: true,
},
Command {
name: "/checkout",
usage: "/checkout <conversation/gitlink> [directory]",
Expand Down Expand Up @@ -2177,17 +2163,15 @@ impl App {
return;
}
if let Some((command, arguments)) = parse_command(raw) {
if !command.action.submits_message() {
if command.takes_argument == arguments.is_empty() {
self.selected_mut()
.show_command_error(format!("usage: {}", command.usage));
} else {
self.selected_mut().input_history = None;
self.selected_mut().composer.take_message();
self.run_local_command(command, arguments);
}
return;
if command.takes_argument == arguments.is_empty() {
self.selected_mut()
.show_command_error(format!("usage: {}", command.usage));
} else {
self.selected_mut().input_history = None;
self.selected_mut().composer.take_message();
self.run_local_command(command, arguments);
}
return;
}
if self.selected().forking {
self.selected_mut()
Expand All @@ -2204,80 +2188,10 @@ impl App {
};
let state = self.selected_mut();
state.reference_notice = None;
// Local commands were handled above; only message-submitting commands
// and ordinary text reach the request path.
let mut human_tree = None;
let mut proposal_base = None;
let mut source_path = None;
let message = if let Some((command, arguments)) = parse_command(&raw) {
debug_assert!(command.action.submits_message());
let Some((name, message)) = parse_update_tree(arguments) else {
self.selected_mut()
.show_command_error(format!("usage: {}", command.usage));
self.selected_mut().composer.restore_message(&raw);
return;
};
let source_tree = match self
.selected()
.source_trees
.iter()
.find(|source| source.name == name)
{
Some(source) => source.head.clone(),
None => {
self.selected_mut()
.show_command_error(format!("no source-tree gitlink at {name:?}"));
self.selected_mut().composer.restore_message(&raw);
return;
}
};
let committed = super::launcher::checkout_for(
&self.repo_dir,
&self.selected().id,
&name,
&source_tree,
)
.and_then(|checkout| {
let (commit, base) = commit_working_tree(message, &source_tree, &checkout)?;
super::launcher::import_local_commit(
&checkout,
&self.repo_dir,
&conversation_protocol::v3::Oid::parse(&commit, "local edit")?,
)?;
Ok((commit, base))
});
match committed {
Ok((tree, base)) => {
human_tree = Some(tree);
proposal_base = Some(base);
}
Err(error) => {
self.selected_mut().show_command_error(error);
return;
}
}
source_path = Some(name);
message.to_string()
} else {
raw
};
self.send_message(
self.selected,
message,
human_tree,
proposal_base,
source_path,
);
self.send_message(self.selected, raw);
}

fn send_message(
&mut self,
index: usize,
message: String,
human_tree: Option<String>,
proposal_base: Option<String>,
source_path: Option<String>,
) {
fn send_message(&mut self, index: usize, message: String) {
let interjecting = self.conversations[index].running;
let should_generate_title = !interjecting
&& self.conversations[index].automatic_title
Expand Down Expand Up @@ -2315,7 +2229,7 @@ impl App {

let tx = self.tx.clone();
let mut options = self.conversations[index].turn_options.clone();
options.source_tree = source_path;
options.source_tree = None;
let conversation = self.conversations[index].id.clone();
let repo_dir = self.repo_dir.clone();
if should_generate_title {
Expand Down Expand Up @@ -2347,8 +2261,8 @@ impl App {
&options,
&conversation,
&message,
human_tree.as_deref(),
proposal_base.as_deref(),
None,
None,
)?;
let _ = committed_tx.send(UiMessage::SubmissionCommitted {
conversation: conversation.clone(),
Expand Down Expand Up @@ -2389,8 +2303,8 @@ impl App {
&options,
&conversation,
&message,
human_tree.as_deref(),
proposal_base.as_deref(),
None,
None,
|commit| {
let _ = event_tx.send(UiMessage::SubmissionCommitted {
conversation: conversation.clone(),
Expand Down Expand Up @@ -2439,7 +2353,6 @@ impl App {
}

fn run_local_command(&mut self, command: &Command, arguments: &str) {
debug_assert!(!command.action.submits_message());
match command.action {
AppAction::Help | AppAction::Commands | AppAction::Archive => {
self.execute_action(command.action)
Expand Down Expand Up @@ -2468,7 +2381,6 @@ impl App {
}
AppAction::From => self.start_from_hash(arguments),
AppAction::Title => self.rename_selected(arguments),
AppAction::UpdateTree => unreachable!("message command reached local dispatch"),
AppAction::NewConversation
| AppAction::Activity
| AppAction::Tools
Expand Down Expand Up @@ -3540,7 +3452,6 @@ impl App {
| AppAction::Model
| AppAction::Reference
| AppAction::Title
| AppAction::UpdateTree
| AppAction::Import => unreachable!("slash action needs arguments"),
}
}
Expand Down Expand Up @@ -3908,21 +3819,6 @@ impl App {
}
}

// Parse only the path as a shell word; the message is ordinary prose.
fn parse_update_tree(arguments: &str) -> Option<(String, &str)> {
arguments
.char_indices()
.filter(|(_, ch)| ch.is_whitespace())
.find_map(|(end, _)| {
let words = shell_words::split(&arguments[..end]).ok()?;
let message = arguments[end..].trim();
match words.as_slice() {
[path] if !path.is_empty() && !message.is_empty() => Some((path.clone(), message)),
_ => None,
}
})
}

fn screen_point(column: u16, row: u16, area: Rect) -> TranscriptPoint {
TranscriptPoint {
row: row.clamp(area.y, area.bottom().saturating_sub(1)),
Expand Down Expand Up @@ -4390,36 +4286,6 @@ mod tests {
assert!(!header.contains("head fffffff"));
}

#[test]
fn update_tree_requires_an_explicit_source_tree() {
let mut conversation = state("talk-1");
conversation.source_trees = vec![
source_tree_diff("main", 'a', 'b', ""),
source_tree_diff("side", 'c', 'd', ""),
];
conversation
.composer
.insert_str("/update-tree include edits");
let (mut app, _) = app_with(vec![conversation]);

app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));

assert_eq!(
app.selected().command_error.as_deref(),
Some("no source-tree gitlink at \"include\"")
);
assert!(!app.selected().running);
assert_eq!(
parse_update_tree("feature/01-change don't drop \"quotes\""),
Some(("feature/01-change".into(), "don't drop \"quotes\""))
);
assert_eq!(
parse_update_tree("\"feature with spaces/01-change\" edit this"),
Some(("feature with spaces/01-change".into(), "edit this"))
);
assert_eq!(parse_update_tree("feature/01-change"), None);
}

fn wait_for_fork(app: &mut App, id: &str) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
Expand Down Expand Up @@ -5013,7 +4879,6 @@ mod tests {
"/from",
"/help",
"/title",
"/update-tree",
"/checkout",
"/import",
"/commands",
Expand Down Expand Up @@ -5052,15 +4917,6 @@ mod tests {

#[test]
fn command_parser_only_claims_catalog_commands() {
assert_eq!(
COMMANDS
.iter()
.filter(|command| command.action.submits_message())
.map(|command| command.name)
.collect::<Vec<_>>(),
["/update-tree"]
);

let (command, arguments) = parse_command("/title A useful title").unwrap();
assert_eq!(command.action, AppAction::Title);
assert_eq!(arguments, "A useful title");
Expand Down Expand Up @@ -5091,10 +4947,7 @@ mod tests {
assert_eq!(command.action, AppAction::Model);
assert_eq!(arguments, "claude-sonnet-5");

let (command, arguments) =
parse_command("/update-tree feature/01-change include this text").unwrap();
assert_eq!(command.action, AppAction::UpdateTree);
assert_eq!(arguments, "feature/01-change include this text");
assert!(parse_command("/update-tree feature/01-change include this text").is_none());

let (command, arguments) = parse_command("/help").unwrap();
assert_eq!(command.action, AppAction::Help);
Expand Down Expand Up @@ -5318,9 +5171,7 @@ mod tests {
.join("\n");
assert!(rendered.contains("> /from <commit> — start a conversation from a completed turn"));
assert!(rendered.contains("/title <new title> — rename the selected conversation"));
assert!(
rendered.contains("/update-tree <conversation/gitlink> <message> — submit local edits")
);
assert!(rendered.contains("/checkout <conversation/gitlink> [directory]"));

app.selected_mut().composer = Composer::default();
app.selected_mut().composer.insert_str("/model sonnet-5");
Expand Down Expand Up @@ -6740,7 +6591,7 @@ mod tests {
app.conversations[0].automatic_title = false;
app.conversations[0].composer.insert_str("first draft");
app.conversations[1].composer.insert_str("second draft");
app.send_message(0, "Integrate the imported PR base".into(), None, None, None);
app.send_message(0, "Integrate the imported PR base".into());
assert_eq!(app.selected().id, "talk-2");
assert_eq!(app.conversations[0].composer.text, "first draft");
assert_eq!(app.conversations[1].composer.text, "second draft");
Expand Down
7 changes: 2 additions & 5 deletions rust/crates/caos-cli/src/bin/tui/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,8 @@ fn install_model_secret(root: &Path, key: &str, images: &[&str]) -> Result<Vec<S
Ok(done)
}

/// `.caos-secrets` must never become tracked: the tui's own `/update-tree`
/// runs `git add -A`, which would fold the store into a turn commit pushed to
/// the server. When git does not already ignore it, add the rule to the
/// repo-local `.git/info/exclude` — never the tracked `.gitignore`, since a
/// setup step must not dirty the working tree.
/// Keep the local secret store out of Git commits. Use `.git/info/exclude`
/// so setup does not change the repository's tracked `.gitignore`.
fn ensure_store_ignored(root: &Path) -> Result<Option<String>, String> {
let status = Command::new("git")
.args(["check-ignore", "-q", "--", SECRETS_DIR])
Expand Down
Loading
Loading