Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ No Rust toolchain is needed system-wide; the flake pins it.
| `rust-toolchain.toml` | Pins the compiler (`stable` + clippy/rustfmt/rust-src) and the static `musl` target |
| `Cargo.toml` | Workspace root (members + shared release profile) |
| `crates/caos/` | The `caos` crate: shared `lib.rs` + `caos` and `caos-cli` binaries |
| `desktop/` | Native Tauri client, kept in its own Cargo workspace so desktop dependencies never enter worker images |
| `crates/server/` | The `server` crate → `caos-server` |
| `crates/worker-*/` | The worker crates |
| `build-builtins.sh` | Bootstraps the seeded core and publishes `refs/caos/seed` |
Expand Down Expand Up @@ -87,10 +88,26 @@ the signal to trust before committing.
> files are included, but new files are not). After adding a new source file,
> `git add` it before building.

### Desktop client

The desktop client uses the same conversation engine as `caos tui` and is
scoped to the Git repository it is launched from. Its transcript scrolls
independently while the composer stays pinned to the bottom of the window.

```bash
nix run .#caos-desktop
```

Build it without launching with `nix build .#caos-desktop`. The desktop remains
a separate Cargo workspace, and bare `nix build` continues to build only the
CLI and daemon host tools. See [`desktop/README.md`](desktop/README.md) for the
direct Cargo command, environment overrides, and current CLI-parity scope.

## Building

```bash
nix build .#caos # ./result/bin/{caos,caos-cli}
nix build .#caos-desktop # ./result/bin/caos-desktop
nix build .#server # ./result/bin/server
```

Expand Down
67 changes: 21 additions & 46 deletions crates/caos/src/bin/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@ use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};

use caos::chat::{
archive_user_conversation, compare_and_set_conversation_title, conversation_load,
conversation_load_at, conversation_reference, conversation_snapshot, describe_tool_set,
first_available_conversation_name, fork_conversation, generate_conversation_title,
interrupt_request, invite_user_to_conversation, list_user_conversations,
publish_user_conversation, resume_request, run_chat_turn, set_conversation_title,
submit_interjection, unarchive_user_conversation, ConversationLoad, ConversationRole,
ConversationSnapshot, InviteOutcome, ToolSetDescription, TurnEvent, TurnOptions, TurnOutcome,
TurnPhase, UserConversationStatus, UserConversationSummary, WorkspaceDiff, DEFAULT_MODEL,
archive_user_conversation, automatic_conversation_title, compare_and_set_conversation_title,
conversation_load, conversation_load_at, conversation_reference, conversation_snapshot,
describe_tool_set, first_available_conversation_name, fork_conversation, fresh_conversation_id,
generate_conversation_title, interrupt_request, invite_user_to_conversation,
list_user_conversations, publish_user_conversation, resume_request, run_chat_turn,
set_conversation_title, submit_interjection, unarchive_user_conversation, ConversationLoad,
ConversationRole, ConversationSnapshot, InviteOutcome, ToolSetDescription, TurnEvent,
TurnOptions, TurnOutcome, TurnPhase, UserConversationStatus, UserConversationSummary,
WorkspaceDiff, DEFAULT_MODEL,
};
use caos::workspace::{
commit_working_tree, fetch_remote_branch_tip, load_conversation_workspace,
local_default_branch_tip, prepare_publish_workspace, publish_conversation_pr,
publish_merge_target, remote_default_branch,
};
use caos::{GitTransport, Transport};
use ratatui_core::buffer::{Buffer, CellWidth};
Expand All @@ -22,12 +28,6 @@ use ratatui_crossterm::crossterm::event::{
};

use super::args::Args;
use super::workspace::{
commit_working_tree, fetch_remote_branch_tip, load_conversation_workspace,
local_default_branch_tip, prepare_publish_workspace, publish_conversation_pr,
publish_merge_target, remote_default_branch,
};

#[path = "ui.rs"]
pub(crate) mod ui;

Expand All @@ -39,21 +39,6 @@ fn collapse_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn automatic_title(prompt: &str) -> String {
const MAX_CHARS: usize = 60;

let title = collapse_whitespace(prompt);
if title.chars().count() <= MAX_CHARS {
return title;
}

title
.chars()
.take(MAX_CHARS - 1)
.chain(std::iter::once('…'))
.collect()
}

fn message_preview(text: &str, max_cells: u16) -> String {
let text = collapse_whitespace(text);
if max_cells == 0 {
Expand Down Expand Up @@ -1327,7 +1312,7 @@ impl ConversationState {

fn apply_automatic_title(&mut self, prompt: &str) {
if !self.automatic_title_fallback_applied {
let fallback = automatic_title(prompt);
let fallback = automatic_conversation_title(prompt);
if self.automatic_title {
self.title = fallback.clone();
}
Expand Down Expand Up @@ -3771,19 +3756,6 @@ fn screen_point(column: u16, row: u16, area: Rect) -> TranscriptPoint {
}
}

fn fresh_conversation_id(t: &GitTransport, user: &str) -> Result<String, String> {
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|error| format!("reading the clock: {error}"))?
.as_nanos();
let descriptor = format!(
"caos conversation\ncreator {user}\ncreated {created}\nprocess {}\n",
std::process::id()
);
t.put_object("blob", descriptor.as_bytes())
.map(|id| id.to_string())
}

fn new_conversation_options(
mut options: TurnOptions,
requested_base: Option<String>,
Expand Down Expand Up @@ -4279,12 +4251,15 @@ mod tests {
#[test]
fn automatic_titles_collapse_whitespace_and_limit_unicode_scalars() {
assert_eq!(
automatic_title(" Review\t the\nλ parser "),
automatic_conversation_title(" Review\t the\nλ parser "),
"Review the λ parser"
);
assert_eq!(automatic_title(&"界".repeat(60)), "界".repeat(60));
assert_eq!(
automatic_title(&"界".repeat(61)),
automatic_conversation_title(&"界".repeat(60)),
"界".repeat(60)
);
assert_eq!(
automatic_conversation_title(&"界".repeat(61)),
format!("{}…", "界".repeat(59))
);
}
Expand Down
1 change: 0 additions & 1 deletion crates/caos/src/bin/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ use ratatui_crossterm::CrosstermBackend;

mod app;
mod args;
mod workspace;

use app::{ui::render, App, MouseAction, View};
use args::{usage, Args};
Expand Down
67 changes: 45 additions & 22 deletions crates/caos/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,8 @@ fn remote_refs(
.collect())
}

fn validate_conversation_title(title: &str) -> Result<&str, String> {
/// Trim and validate a title before a client displays or persists it.
pub fn normalize_conversation_title(title: &str) -> Result<&str, String> {
if title.chars().any(char::is_control) {
return Err("conversation title must contain no control characters".to_string());
}
Expand Down Expand Up @@ -1241,7 +1242,7 @@ fn create_conversation_title_if_absent(
id: &str,
title: &str,
) -> Result<(), String> {
let title = validate_conversation_title(title)?;
let title = normalize_conversation_title(title)?;
let title_ref = conversation_title_ref(id)?;
if remote_ref(t, &title_ref)?.is_some() {
return Ok(());
Expand Down Expand Up @@ -1345,7 +1346,7 @@ pub fn fork_conversation(
from: &str,
) -> Result<String, String> {
validate_hash(from, "fork source")?;
let title = validate_conversation_title(title)?;
let title = normalize_conversation_title(title)?;
let refname = conversation_ref(id)?;
let title_ref = conversation_title_ref(id)?;
let active_ref = user_conversation_ref(user, UserConversationStatus::Active, id)?;
Expand Down Expand Up @@ -1384,7 +1385,7 @@ pub fn fork_conversation(
}

pub fn set_conversation_title(t: &GitTransport, id: &str, title: &str) -> Result<(), String> {
let title = validate_conversation_title(title)?;
let title = normalize_conversation_title(title)?;
let hash = t.put_object("blob", title.as_bytes())?.to_string();
let title_ref = conversation_title_ref(id)?;
t.git_capture(
Expand All @@ -1407,8 +1408,8 @@ pub fn compare_and_set_conversation_title(
expected: &str,
title: &str,
) -> Result<bool, String> {
let expected = validate_conversation_title(expected)?;
let title = validate_conversation_title(title)?;
let expected = normalize_conversation_title(expected)?;
let title = normalize_conversation_title(title)?;
let expected_hash = t.put_object("blob", expected.as_bytes())?.to_string();
let candidate = t.put_object("blob", title.as_bytes())?.to_string();
let title_ref = conversation_title_ref(id)?;
Expand Down Expand Up @@ -1615,7 +1616,7 @@ pub fn list_user_conversations(
}
let title = String::from_utf8(bytes)
.map_err(|_| format!("conversation {id:?} title is not UTF-8"))?;
validate_conversation_title(&title)?.to_string()
normalize_conversation_title(&title)?.to_string()
} else {
id.clone()
};
Expand Down Expand Up @@ -1694,6 +1695,38 @@ pub fn first_available_conversation_name<'a>(names: impl IntoIterator<Item = &'a
unreachable!("the integer conversation-name space is not finite")
}

/// Create a presentation-independent id for a conversation before its first
/// durable event exists.
pub fn fresh_conversation_id(t: &GitTransport, user: &str) -> Result<String, String> {
let created = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|error| format!("reading the clock: {error}"))?
.as_nanos();
let descriptor = format!(
"caos conversation\ncreator {user}\ncreated {created}\nprocess {}\n",
std::process::id()
);
t.put_object("blob", descriptor.as_bytes())
.map(|id| id.to_string())
}

/// Derive the immediate fallback title shown while best-effort title
/// generation is still running.
pub fn automatic_conversation_title(prompt: &str) -> String {
const MAX_CHARS: usize = 60;

let title = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
if title.chars().count() <= MAX_CHARS {
return title;
}

title
.chars()
.take(MAX_CHARS - 1)
.chain(std::iter::once('…'))
.collect()
}

fn value_text(value: &Value) -> String {
value
.as_str()
Expand Down Expand Up @@ -2096,7 +2129,7 @@ fn parse_generated_title(text: &str) -> Result<String, String> {
if title.chars().count() > 60 {
return Err("conversation title result exceeds 60 characters".to_string());
}
validate_conversation_title(&title).map(str::to_string)
normalize_conversation_title(&title).map(str::to_string)
}

pub fn describe_tool_set(
Expand Down Expand Up @@ -3115,17 +3148,7 @@ fn waterfall_string(value: &Value, key: &str, target: &mut Option<String>) -> Re
}

fn default_title(message: &str) -> String {
const MAX_CHARS: usize = 60;
let compact = message.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.chars().count() <= MAX_CHARS {
compact
} else {
compact
.chars()
.take(MAX_CHARS - 1)
.chain(std::iter::once('…'))
.collect()
}
automatic_conversation_title(message)
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -3673,7 +3696,7 @@ mod tests {
#[test]
fn canonical_titles_reject_controls() {
assert_eq!(
validate_conversation_title(" useful title ").unwrap(),
normalize_conversation_title(" useful title ").unwrap(),
"useful title"
);
for title in [
Expand All @@ -3683,11 +3706,11 @@ mod tests {
"nul\0byte",
] {
assert!(
validate_conversation_title(title).is_err(),
normalize_conversation_title(title).is_err(),
"accepted {title:?}"
);
}
assert!(validate_conversation_title(" ").is_err());
assert!(normalize_conversation_title(" ").is_err());
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions crates/caos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ use gix::objs::WriteTo;
pub mod chat;
pub use chat::{cli_chat, cli_talk};

pub mod workspace;

mod eval;
pub use eval::cli_eval_path;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ use std::path::Path;
use std::process::{Command, Output, Stdio};

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PreparedPublishWorkspace {
pub(crate) head: String,
pub(crate) tree: String,
pub struct PreparedPublishWorkspace {
head: String,
tree: String,
}

/// Check out a conversation's head commit in the local working tree.
///
/// This is deliberately client policy rather than part of the chat engine:
/// the TUI chooses when to mutate the checkout and requires confirmation before
/// clients choose when to mutate the checkout and require confirmation before
/// calling it. Rather than applying the base-to-head diff as unstaged changes,
/// this moves the local HEAD onto the conversation head commit so the checkout
/// exactly matches it.
pub(crate) fn load_conversation_workspace(head: &str, cwd: &Path) -> Result<(), String> {
pub fn load_conversation_workspace(head: &str, cwd: &Path) -> Result<(), String> {
let dirty = capture_required(
"git",
&["status", "--porcelain=v1", "--untracked-files=all"],
Expand Down Expand Up @@ -43,7 +43,7 @@ pub(crate) fn load_conversation_workspace(head: &str, cwd: &Path) -> Result<(),
/// committed the changes themselves), nothing is committed and the current
/// `HEAD` is returned. `git add -A` respects `.gitignore`, so the commit
/// mirrors what a normal commit of the working tree would contain.
pub(crate) fn commit_working_tree(message: &str, cwd: &Path) -> Result<String, String> {
pub fn commit_working_tree(message: &str, cwd: &Path) -> Result<String, String> {
capture_required("git", &["add", "-A"], cwd)?;
// `git diff --cached --quiet` exits non-zero exactly when the index differs
// from HEAD, i.e. there is something to commit.
Expand All @@ -59,7 +59,7 @@ pub(crate) fn commit_working_tree(message: &str, cwd: &Path) -> Result<String, S
/// Name the exact commit the ordinary publish turn must merge. A stacked
/// snapshot keeps the selected base's tree but shares the conversation's base,
/// so `merge` applies only this conversation's delta.
pub(crate) fn publish_merge_target(
pub fn publish_merge_target(
conversation_base: &str,
publish_base: &str,
stacked: bool,
Expand Down Expand Up @@ -87,7 +87,7 @@ pub(crate) fn publish_merge_target(
)
}

pub(crate) fn prepare_publish_workspace(
pub fn prepare_publish_workspace(
head: &str,
target: &str,
cwd: &Path,
Expand Down Expand Up @@ -175,7 +175,7 @@ pub(crate) fn prepare_publish_workspace(
///
/// The chat core has already merged, resolved, tested, and removed harness
/// state. Keep only that tree as one commit above the exact fetched PR base.
pub(crate) fn publish_conversation_pr(
pub fn publish_conversation_pr(
name: &str,
workspace: &PreparedPublishWorkspace,
pr_base: &str,
Expand Down Expand Up @@ -240,7 +240,7 @@ pub(crate) fn publish_conversation_pr(
/// fetch`, so it stays instant (e.g. on every Ctrl+N) instead of blocking on
/// round-trips to `origin`. Publishing a PR still fetches, where a fresh remote
/// tip matters.
pub(crate) fn local_default_branch_tip(cwd: &Path) -> Result<(String, String), String> {
pub fn local_default_branch_tip(cwd: &Path) -> Result<(String, String), String> {
// `refs/remotes/origin/HEAD` is the local symref recording origin's default
// branch; it is set at clone time and refreshed by `git remote set-head`.
let head_ref = capture_required("git", &["symbolic-ref", "refs/remotes/origin/HEAD"], cwd)
Expand All @@ -260,7 +260,7 @@ pub(crate) fn local_default_branch_tip(cwd: &Path) -> Result<(String, String), S
Ok((branch, commit))
}

pub(crate) fn remote_default_branch(cwd: &Path) -> Result<String, String> {
pub fn remote_default_branch(cwd: &Path) -> Result<String, String> {
let output = command_output("git", &["ls-remote", "--symref", "origin", "HEAD"], cwd)?;
let stdout = require_success("git", output)?;
parse_remote_default_branch(&String::from_utf8_lossy(&stdout))
Expand All @@ -287,7 +287,7 @@ fn parse_remote_default_branch(output: &str) -> Result<String, String> {
Err("origin HEAD did not advertise a default branch".to_string())
}

pub(crate) fn fetch_remote_branch_tip(branch: &str, cwd: &Path) -> Result<String, String> {
pub fn fetch_remote_branch_tip(branch: &str, cwd: &Path) -> Result<String, String> {
let remote_ref = format!("refs/heads/{branch}");
let tracking_ref = format!("refs/remotes/origin/{branch}");
let refspec = format!("+{remote_ref}:{tracking_ref}");
Expand Down
Loading