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
105 changes: 80 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ on:

env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
fmt:
name: Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
Expand All @@ -24,46 +30,95 @@ jobs:
clippy:
name: Clippy
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --workspace -- -D warnings
- run: cargo clippy --workspace --all-targets --locked -- -D warnings

test:
name: Test
docs:
name: Docs
runs-on: ubuntu-latest
timeout-minutes: 20
env:
RUSTDOCFLAGS: -D warnings
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace
- run: cargo doc --workspace --no-deps --locked

build:
name: Build
runs-on: ubuntu-latest
test:
name: Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --workspace
- run: cargo build --workspace --locked
- run: cargo test --workspace --locked

windows-build:
name: Build (Windows)
runs-on: windows-latest
msrv:
name: MSRV check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@v5
- name: Read MSRV from Cargo.toml
id: msrv
shell: bash
run: |
set +e
msrv=$(grep -E '^rust-version' Cargo.toml | head -n1 | sed -E 's/.*"([^"]+)".*/\1/')
set -e
if [ -z "$msrv" ]; then
echo "No rust-version set in Cargo.toml; skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "version=$msrv" >> "$GITHUB_OUTPUT"
fi
- uses: dtolnay/rust-toolchain@master
if: steps.msrv.outputs.skip != 'true'
with:
toolchain: ${{ steps.msrv.outputs.version }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --workspace
if: steps.msrv.outputs.skip != 'true'
- if: steps.msrv.outputs.skip != 'true'
run: cargo check --workspace --all-targets --locked

windows-test:
name: Test (Windows)
runs-on: windows-latest
audit:
name: Security audit
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace
- uses: actions/checkout@v5
- uses: rustsec/audit-check@v2.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}

deny:
name: cargo-deny
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
arguments: --all-features

typos:
name: Typos
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: crate-ci/typos@master
25 changes: 25 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# typos configuration: https://github.com/crate-ci/typos
# Allowlist false positives from test fixtures, technical names, and common
# short tokens that the dictionary flags too aggressively.

[default]
extend-ignore-re = [
# Ignore short truncated-string test fixtures, e.g. truncate("hello", 3) == "hel"
'"hel"',
'"Hello Worl"',
'b"fo"',
]

[default.extend-words]
# Project / crate names
ratatui = "ratatui"
# Accepted alternate spellings used in this codebase
unparseable = "unparseable"
Invokable = "Invokable"

[files]
extend-exclude = [
"target/",
"*.lock",
"tests/sockudo/",
]
4 changes: 2 additions & 2 deletions Cargo.lock

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

43 changes: 43 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# cargo-deny configuration for TinyHarness (MIT-licensed).
# Docs: https://embarkstudios.github.io/cargo-deny/

[advisories]
# Fail on any RUSTSEC advisory. Add IDs here with a reason if intentionally ignored.
ignore = []

[licenses]
# Permissive licenses compatible with redistribution under MIT.
# Copyleft licenses (GPL/LGPL/AGPL/MPL/EPL/CDDL/SSPL/BUSL) are intentionally
# omitted — add only after legal review.
allow = [
"MIT",
"MIT-0",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-2-Clause-Patent",
"BSD-3-Clause",
"ISC",
"0BSD",
"Zlib",
"CC0-1.0",
"Unlicense",
"BSL-1.0",
"CDLA-Permissive-2.0", # Mozilla CA bundle (webpki-root-certs)
"Unicode-3.0", # unicode-ident, icu
"Unicode-DFS-2016",
"OpenSSL",
]
confidence-threshold = 0.8

[bans]
multiple-versions = "warn"
wildcards = "deny"
# Workspace members aren't published; allow path-only wildcards if any appear.
allow-wildcard-paths = true

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
6 changes: 4 additions & 2 deletions src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,10 @@ pub async fn run_agent_loop(
break;
}

// Show spinner animation while waiting for first chunk
if waiting_for_first_chunk {
// Show spinner animation while waiting for first chunk.
// When show_thinking is enabled, skip the spinner — the
// model's actual thinking content will stream in instead.
if waiting_for_first_chunk && !ctx.show_thinking {
let frame = SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.len()];
spinner_idx += 1;
if has_shown_spinner {
Expand Down
20 changes: 8 additions & 12 deletions src/agent/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,14 +210,14 @@ pub fn prompt_for_url(
/// Resolve the URL to use for the given provider based on (in order of
/// precedence):
/// 1. `cli_url` (the value of `--url` if passed)
/// 2. `last_provider_url` from settings (only if it matches `kind`)
/// 2. URL from `settings.provider_urls` for the given `kind`
/// 3. the hardcoded default for `kind`
pub fn resolve_url(kind: ProviderKind, cli_url: &str, settings: &Settings) -> String {
if !cli_url.is_empty() {
return cli_url.to_string();
}
if let Some(saved) = &settings.last_provider_url {
return saved.clone();
if let Some(saved) = settings.get_url_for(kind) {
return saved.to_string();
}
default_url_for(kind).to_string()
}
Expand All @@ -240,7 +240,7 @@ pub fn save_provider_settings(kind: ProviderKind, url: &str) {
// Load existing settings to preserve unrelated fields (mode, model, etc.)
let mut s = tinyharness_lib::config::load_settings();
s.last_provider = kind;
s.last_provider_url = Some(url.to_string());
s.set_url_for(kind, url.to_string());
save_settings(&s);
}

Expand Down Expand Up @@ -495,10 +495,8 @@ mod tests {

#[test]
fn resolve_url_cli_overrides_everything() {
let s = Settings {
last_provider_url: Some("http://saved:1234".to_string()),
..Settings::default()
};
let mut s = Settings::default();
s.set_url_for(ProviderKind::Ollama, "http://saved:1234".to_string());
assert_eq!(
resolve_url(ProviderKind::Ollama, "http://cli:9999", &s),
"http://cli:9999"
Expand All @@ -507,10 +505,8 @@ mod tests {

#[test]
fn resolve_url_uses_saved_when_no_cli() {
let s = Settings {
last_provider_url: Some("http://saved:1234".to_string()),
..Settings::default()
};
let mut s = Settings::default();
s.set_url_for(ProviderKind::Ollama, "http://saved:1234".to_string());
assert_eq!(
resolve_url(ProviderKind::Ollama, "", &s),
"http://saved:1234"
Expand Down
4 changes: 2 additions & 2 deletions src/commands/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ fn dump_provider_diagnostics(file: &mut std::fs::File, _ctx: &CommandContext) {

writeln!(file, "Provider kind: {}", settings.last_provider).unwrap();

if let Some(url) = &settings.last_provider_url {
if let Some(url) = settings.get_current_url() {
writeln!(file, "Provider URL: {}", url).unwrap();
} else {
writeln!(file, "Provider URL: (not set)").unwrap();
Expand All @@ -296,7 +296,7 @@ fn dump_provider_diagnostics(file: &mut std::fs::File, _ctx: &CommandContext) {
writeln!(
file,
"Current model: {}",
settings.last_model.as_deref().unwrap_or("(none)")
settings.get_current_model().unwrap_or("(none)")
)
.unwrap();

Expand Down
8 changes: 4 additions & 4 deletions src/commands/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ const MAX_PENDING_IMAGES: usize = 10;
/// Execute the `/image` command.
///
/// Usage:
/// /image <path> — Attach an image file
/// /image — Show pending images
/// /image clear — Clear all pending images
/// /image drop <n> — Remove a specific pending image by index
/// `/image <path>` — Attach an image file
/// `/image` — Show pending images
/// `/image clear` — Clear all pending images
/// `/image drop <n>` — Remove a specific pending image by index
pub fn execute(ctx: &mut CommandContext, arg: Option<&str>) {
match arg {
None | Some("") => {
Expand Down
6 changes: 4 additions & 2 deletions src/commands/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ async_command!(
execute_select(&mut ctx.output, &mut *p, &name).await?;

let mut settings = load_settings();
settings.last_model = p.current_model();
save_settings(&settings);
if let Some(model) = p.current_model() {
settings.set_model_for(settings.last_provider, model);
save_settings(&settings);
}

Ok(CommandResult::Ok)
}
Expand Down
4 changes: 2 additions & 2 deletions src/commands/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ pub trait Command: Send + Sync {
/// One-line description for /help.
fn description(&self) -> &'static str;

/// Usage string (e.g., "/model <name>"). Defaults to the command name.
/// Usage string (e.g., `/model <name>`). Defaults to the command name.
fn usage(&self) -> &'static str {
self.name()
}
Expand Down Expand Up @@ -310,7 +310,7 @@ pub struct CommandRegistry {
/// Alias descriptions for /help display.
alias_descriptions: HashMap<&'static str, &'static str>,
/// Pre-computed (usage, description) pairs for /help, including aliases.
/// Populated by [`freeze_descriptions`] after all registrations are done.
/// Populated by `freeze_descriptions` after all registrations are done.
descriptions: Vec<(&'static str, &'static str)>,
/// Subcommand completions for tab-completion (command name → argument completions).
subcommands: HashMap<String, Vec<String>>,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn execute_summary(out: &mut Output, settings: &tinyharness_lib::config::Setting
let provider_str = format!("{}", settings.last_provider);
let _ = writeln!(out, "{BOLD}│{RESET} Provider: {BLUE}{provider_str}{RESET}",);

match &settings.last_model {
match settings.get_current_model() {
Some(model) => {
let _ = writeln!(out, "{BOLD}│{RESET} Model: {BLUE}{model}{RESET}");
}
Expand Down
Loading
Loading