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.

14 changes: 11 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
NAME := offcode
VERSION := $(shell grep '^version' Cargo.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/')
DIST := dist
BINDIR ?= /usr/local/bin

# ── native build ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -84,8 +85,14 @@ dist: cross-all macos-universal

.PHONY: install
install: build
cp target/release/$(NAME) /usr/local/bin/$(NAME)
@echo "Installed $(NAME) to /usr/local/bin/$(NAME)"
cp target/release/$(NAME) $(BINDIR)/$(NAME)
@echo "Installed $(NAME) to $(BINDIR)/$(NAME)"

.PHONY: symstall
symstall: build
rm -f $(BINDIR)/$(NAME)
ln -sf $(CURDIR)/target/release/$(NAME) $(BINDIR)/$(NAME)
@echo "Symlinked $(BINDIR)/$(NAME) -> $(CURDIR)/target/release/$(NAME)"

.PHONY: install-user
install-user: build
Expand All @@ -98,7 +105,8 @@ help:
@echo "offcode build targets:"
@echo " make build Native release build"
@echo " make run Run in dev mode"
@echo " make install Install to /usr/local/bin"
@echo " make install Install to \$$(BINDIR) (default /usr/local/bin)"
@echo " make symstall Symlink binary into \$$(BINDIR) (rebuild-friendly)"
@echo " make install-user Install to ~/.local/bin"
@echo " make cross-all Cross-compile all targets (needs cross + Docker)"
@echo " make x86_64-unknown-linux-musl Linux x86_64 static"
Expand Down
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ pub struct Config {
pub num_ctx: u32,
pub show_thinking: bool,
pub max_tool_iters: u32,
#[serde(default = "default_yolo")]
pub yolo: bool,
#[serde(skip)]
pub no_ctx: bool,
}

fn default_yolo() -> bool { false }

impl Default for Config {
fn default() -> Self {
Self {
Expand All @@ -34,6 +38,7 @@ impl Default for Config {
num_ctx: 16384,
show_thinking: false,
max_tool_iters: 30,
yolo: false,
no_ctx: false,
}
}
Expand Down
185 changes: 154 additions & 31 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ mod ui;
use config::Config;
use ollama::{ChatRequest, Client, Message, Options};

pub enum ConfirmAction {
Accept,
Reject(String),
Modify(serde_json::Value),
Comment(String),
}

fn main() {
let raw_args: Vec<String> = std::env::args().collect();
let mut cfg = Config::load();
Expand Down Expand Up @@ -158,6 +165,11 @@ fn run_repl(cfg: Config, client: Client) {
println!("{}History cleared.{}", ui::DIM, ui::RESET);
}
"/tools" => tools::print_list(),
"/yolo" => {
cfg.yolo = !cfg.yolo;
let state = if cfg.yolo { "on (tools run without prompting)" } else { "off (prompt before each tool call)" };
println!("{}Yolo mode: {}{}", ui::DIM, state, ui::RESET);
}
"/config" => {
println!("{}", toml::to_string_pretty(&cfg).unwrap_or_default());
}
Expand Down Expand Up @@ -230,53 +242,78 @@ fn run_turn(cfg: &Config, client: &Client, messages: &mut Vec<Message>, input: &
);

match result {
Ok((content, Some(calls))) => {
Ok((content, Some(mut calls))) => {
println!("{}", ui::RESET);

// Per-call confirmation: may mutate args on Modify, or produce
// a comment appended after the tool result.
let mut actions: Vec<ConfirmAction> = Vec::with_capacity(calls.len());
for call in calls.iter_mut() {
print_tool_call(&call.function.name, &call.function.arguments);
let action = if cfg.yolo {
ConfirmAction::Accept
} else {
prompt_confirm_stdin()
};
if let ConfirmAction::Modify(ref new_args) = action {
call.function.arguments = new_args.clone();
println!("{} (args modified){}", ui::DIM, ui::RESET);
}
actions.push(action);
}

messages.push(Message {
role: "assistant".to_string(),
content: content.clone(),
tool_calls: Some(calls.clone()),
});

for call in &calls {
for (call, action) in calls.iter().zip(actions.into_iter()) {
let name = &call.function.name;
let args = &call.function.arguments;
println!(
"\n{}{}⚙ {}{}{}{}",
ui::BOLD,
ui::BRIGHT_YELLOW,
ui::RESET,
ui::CYAN,
name,
ui::RESET
);
if let Some(obj) = args.as_object() {
for (k, v) in obj {
let val = match v {
serde_json::Value::String(s) => {
let first: String =
s.lines().next().unwrap_or("").chars().take(80).collect();
if s.lines().count() > 1 {
format!("{first}…")
} else {
first
}
}
other => other.to_string(),

let (tool_result, extra_user) = match action {
ConfirmAction::Reject(reason) => {
let msg = if reason.is_empty() {
"Tool call rejected by user.".to_string()
} else {
format!("Tool call rejected by user: {reason}")
};
println!(" {} {k}: {}{}", ui::DIM, val, ui::RESET);
println!("{} ✗ rejected{}", ui::YELLOW, ui::RESET);
(msg, None)
}
}
let tool_result = tools::execute(name, args);
let preview: Vec<&str> = tool_result.lines().take(4).collect();
if !preview.is_empty() {
println!("{} → {}{}", ui::DIM, preview.join(" | "), ui::RESET);
}
ConfirmAction::Comment(text) => {
let r = tools::execute(name, args);
let preview: Vec<&str> = r.lines().take(4).collect();
if !preview.is_empty() {
println!("{} → {}{}", ui::DIM, preview.join(" | "), ui::RESET);
}
(r, Some(text))
}
ConfirmAction::Accept | ConfirmAction::Modify(_) => {
let r = tools::execute(name, args);
let preview: Vec<&str> = r.lines().take(4).collect();
if !preview.is_empty() {
println!("{} → {}{}", ui::DIM, preview.join(" | "), ui::RESET);
}
(r, None)
}
};

messages.push(Message {
role: "tool".to_string(),
content: tool_result,
tool_calls: None,
});

if let Some(text) = extra_user {
println!("{} + user note: {}{}", ui::DIM, text, ui::RESET);
messages.push(Message {
role: "user".to_string(),
content: text,
tool_calls: None,
});
}
}
println!();
}
Expand Down Expand Up @@ -365,6 +402,7 @@ fn print_repl_help() {
println!(" {c}/model{r} {d}List available models (with capabilities){r}");
println!(" {c}/model <name>{r} {d}Switch model{r}");
println!(" {c}/config{r} {d}Show config{r}");
println!(" {c}/yolo{r} {d}Toggle yolo mode (auto-approve tool calls){r}");
println!(" {c}/exit{r} {d}Quit{r}");
}

Expand Down Expand Up @@ -405,6 +443,91 @@ fn print_model_list(client: &Client, selected: &str) {
);
}

fn print_tool_call(name: &str, args: &serde_json::Value) {
println!(
"\n{}{}⚙ {}{}{}{}",
ui::BOLD,
ui::BRIGHT_YELLOW,
ui::RESET,
ui::CYAN,
name,
ui::RESET
);
if let Some(obj) = args.as_object() {
for (k, v) in obj {
let val = match v {
serde_json::Value::String(s) => {
let first: String =
s.lines().next().unwrap_or("").chars().take(80).collect();
if s.lines().count() > 1 {
format!("{first}…")
} else {
first
}
}
other => other.to_string(),
};
println!(" {} {k}: {}{}", ui::DIM, val, ui::RESET);
}
}
}

fn prompt_confirm_stdin() -> ConfirmAction {
use std::io::BufRead;
let stdin = io::stdin();
loop {
print!(
"{}[y]accept [n]reject [c <note>]comment [m <json>]modify ? {}",
ui::BRIGHT_GREEN,
ui::RESET
);
io::stdout().flush().ok();

let mut line = String::new();
if stdin.lock().read_line(&mut line).is_err() {
return ConfirmAction::Reject("stdin closed".into());
}
let trimmed = line.trim();

if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes") {
return ConfirmAction::Accept;
}
if trimmed.eq_ignore_ascii_case("n") || trimmed.eq_ignore_ascii_case("no") {
return ConfirmAction::Reject(String::new());
}
let (head, rest) = match trimmed.split_once(char::is_whitespace) {
Some((h, r)) => (h, r.trim()),
None => (trimmed, ""),
};
match head {
"r" | "reject" => return ConfirmAction::Reject(rest.to_string()),
"c" | "comment" => {
if rest.is_empty() {
println!("{} (comment text required){}", ui::YELLOW, ui::RESET);
continue;
}
return ConfirmAction::Comment(rest.to_string());
}
"m" | "modify" => {
if rest.is_empty() {
println!("{} (json args required){}", ui::YELLOW, ui::RESET);
continue;
}
match serde_json::from_str::<serde_json::Value>(rest) {
Ok(v) => return ConfirmAction::Modify(v),
Err(e) => {
println!("{} invalid JSON: {}{}", ui::YELLOW, e, ui::RESET);
continue;
}
}
}
_ => {
println!("{} unknown response{}", ui::YELLOW, ui::RESET);
}
}
}
}

fn print_history(messages: &[Message]) {
for msg in messages.iter().skip(1) {
let (color, label): (&str, &str) = match msg.role.as_str() {
Expand Down
Loading
Loading