Compare commits

...

7 Commits

Author SHA1 Message Date
teernisse
659c084a74 config 2026-02-28 00:13:26 -05:00
Taylor Eernisse
b7561c16d8 fix: clippy lint and multi-spacer distribution
- transcript.rs: use sort_by_key with Reverse instead of sort_by
  (clippy::unnecessary_sort_by)
- flex.rs: distribute extra space proportionally among multiple spacers
  instead of picking a single winner
- .gitignore: add .DS_Store entries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 17:04:42 -05:00
teernisse
9cbfa06c58 fix: remove jj status check as it was causing divergences for other agents 2026-02-12 12:25:20 -05:00
Taylor Eernisse
90454efe9f fix: install.sh respects CARGO_TARGET_DIR via cargo metadata
The installer hardcoded target/ as the build output directory, but
CARGO_TARGET_DIR (or [build] target-dir in cargo config) can redirect
output anywhere (e.g., /tmp/cargo-target). Now uses cargo metadata
--format-version 1 to get the actual target_directory, falling back
to $SCRIPT_DIR/target if metadata fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:45:21 -05:00
Taylor Eernisse
f35d665c19 fix: install.sh adds smoke test, settings verification, and error recovery
The script now: runs the binary with --test after install to verify it
actually works, prints the statusLine JSON that was written to settings,
handles corrupt/invalid settings.json by backing up and writing fresh,
and emphasizes that Claude Code must be restarted to pick up the change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:54:09 -05:00
Taylor Eernisse
f94a3170b1 fix: install.sh finds binary under custom target triples
Nightly toolchains with CARGO_BUILD_TARGET set (or .cargo/config.toml
[build] target) put the binary in target/<triple>/release/ instead of
target/release/. The installer now falls back to a find search across
all target subdirectories when the standard path doesn't exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:51:36 -05:00
Taylor Eernisse
23d4d59c71 fix: install.sh shows build errors instead of failing silently
cargo build --quiet swallowed compiler errors, and the subshell (...)
didn't reliably propagate exit codes across bash versions. The script
continued past a failed build, then ls/cp failed on the missing binary.

Now: removed --quiet so errors are visible, explicit exit-code check
with common-cause hints (build-essential, libc6-dev), and a secondary
check that the binary actually exists before attempting to install it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:49:45 -05:00
11 changed files with 121 additions and 161 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
# bv (beads viewer) local config and caches # bv (beads viewer) local config and caches
.bv/ .bv/
target/ target/
.DS_Store
src/.DS_Store

View File

@@ -147,7 +147,6 @@
"enabled": true, "enabled": true,
"priority": 1, "priority": 1,
"min_width": 8, "min_width": 8,
"prefer": "auto",
"show_ahead_behind": true, "show_ahead_behind": true,
"show_dirty": true, "show_dirty": true,
"truncate": { "truncate": {

View File

@@ -31,14 +31,40 @@ echo "[ok] jq found"
# ── Build release binary ───────────────────────────────────────────── # ── Build release binary ─────────────────────────────────────────────
echo "" echo ""
echo "Building release binary..." echo "Building release binary..."
(cd "$SCRIPT_DIR" && cargo build --release --quiet) if ! (cd "$SCRIPT_DIR" && cargo build --release); then
echo "[ok] Built: $(ls -lh "$SCRIPT_DIR/target/release/$BINARY_NAME" | awk '{print $5}')" echo ""
echo "ERROR: cargo build failed. Check the output above for details."
echo " Common causes:"
echo " - Missing C compiler: sudo apt install build-essential (Debian/Ubuntu)"
echo " - Missing libc headers: sudo apt install libc6-dev"
exit 1
fi
# Find the binary — respects CARGO_TARGET_DIR, custom target triples, etc.
# Use cargo metadata as the source of truth for the target directory.
TARGET_DIR=$(cd "$SCRIPT_DIR" && cargo metadata --format-version 1 --no-deps 2>/dev/null | jq -r '.target_directory' 2>/dev/null) || true
TARGET_DIR="${TARGET_DIR:-$SCRIPT_DIR/target}"
BINARY="$TARGET_DIR/release/$BINARY_NAME"
if [[ ! -f "$BINARY" ]]; then
# Fall back to searching (handles custom target triples like target/<triple>/release/)
BINARY=$(find "$TARGET_DIR" -name "$BINARY_NAME" -type f -path "*/release/*" ! -name "*.d" 2>/dev/null | head -1)
fi
if [[ -z "${BINARY:-}" || ! -f "$BINARY" ]]; then
echo "ERROR: Build succeeded but binary not found."
echo " Target dir: $TARGET_DIR"
echo " Searched: $TARGET_DIR/*/release/$BINARY_NAME"
echo " Contents: $(ls "$TARGET_DIR/release/" 2>/dev/null || echo '(empty or missing)')"
exit 1
fi
echo "[ok] Built: $(ls -lh "$BINARY" | awk '{print $5}')"
# ── Install binary ─────────────────────────────────────────────────── # ── Install binary ───────────────────────────────────────────────────
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
cp "$SCRIPT_DIR/target/release/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME" cp "$BINARY" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME" chmod +x "$INSTALL_DIR/$BINARY_NAME"
echo "[ok] Installed to $INSTALL_DIR/$BINARY_NAME" BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
echo "[ok] Installed to $BINARY_PATH"
# Verify it's on PATH # Verify it's on PATH
if ! command -v "$BINARY_NAME" &>/dev/null; then if ! command -v "$BINARY_NAME" &>/dev/null; then
@@ -46,33 +72,50 @@ if ! command -v "$BINARY_NAME" &>/dev/null; then
echo " Add to your shell config: export PATH=\"$INSTALL_DIR:\$PATH\"" echo " Add to your shell config: export PATH=\"$INSTALL_DIR:\$PATH\""
fi fi
# Smoke test — verify the binary actually runs
if "$BINARY_PATH" --test --color=never >/dev/null 2>&1; then
echo "[ok] Binary smoke test passed"
else
echo "[warn] Binary smoke test failed (exit $?). It may still work inside Claude Code."
echo " Debug: $BINARY_PATH --test --dump-state=json"
fi
# ── Configure Claude Code settings.json ────────────────────────────── # ── Configure Claude Code settings.json ──────────────────────────────
echo "" echo ""
mkdir -p "$CLAUDE_DIR" mkdir -p "$CLAUDE_DIR"
BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
# The binary runs in a non-TTY context, so force color on. # The binary runs in a non-TTY context, so force color on.
STATUSLINE_CMD="$BINARY_PATH --color=always" STATUSLINE_CMD="$BINARY_PATH --color=always"
if [[ -f "$SETTINGS" ]]; then if [[ -f "$SETTINGS" ]]; then
# Update existing settings.json # Update existing settings.json, preserving all other keys
CURRENT_CMD=$(jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true) CURRENT_CMD=$(jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true)
if [[ -n "$CURRENT_CMD" ]]; then if [[ -n "$CURRENT_CMD" ]]; then
echo "[info] Current statusLine command: $CURRENT_CMD" echo "[info] Previous statusLine command: $CURRENT_CMD"
fi fi
# Write updated settings
TMP="$SETTINGS.tmp.$$" TMP="$SETTINGS.tmp.$$"
jq --arg cmd "$STATUSLINE_CMD" '.statusLine = {"type": "command", "command": $cmd, "padding": 0}' "$SETTINGS" > "$TMP" if jq --arg cmd "$STATUSLINE_CMD" '.statusLine = {"type": "command", "command": $cmd, "padding": 0}' "$SETTINGS" > "$TMP" 2>/dev/null; then
mv "$TMP" "$SETTINGS" mv "$TMP" "$SETTINGS"
echo "[ok] Updated statusLine in $SETTINGS" echo "[ok] Updated statusLine in $SETTINGS"
else
rm -f "$TMP"
echo "[warn] Failed to update $SETTINGS (invalid JSON?). Creating backup and writing fresh."
cp "$SETTINGS" "$SETTINGS.bak"
jq -n --arg cmd "$STATUSLINE_CMD" '{"statusLine": {"type": "command", "command": $cmd, "padding": 0}}' > "$SETTINGS"
echo "[ok] Wrote fresh $SETTINGS (backup: $SETTINGS.bak)"
fi
else else
# Create minimal settings.json # Create minimal settings.json
jq -n --arg cmd "$STATUSLINE_CMD" '{"statusLine": {"type": "command", "command": $cmd, "padding": 0}}' > "$SETTINGS" jq -n --arg cmd "$STATUSLINE_CMD" '{"statusLine": {"type": "command", "command": $cmd, "padding": 0}}' > "$SETTINGS"
echo "[ok] Created $SETTINGS" echo "[ok] Created $SETTINGS"
fi fi
# Show what was written so the user can verify
echo ""
echo " statusLine config:"
jq '.statusLine' "$SETTINGS" 2>/dev/null || echo " (could not read settings)"
# ── Symlink config ─────────────────────────────────────────────────── # ── Symlink config ───────────────────────────────────────────────────
CONFIG_SRC="$SCRIPT_DIR/statusline.json" CONFIG_SRC="$SCRIPT_DIR/statusline.json"
CONFIG_DST="$CLAUDE_DIR/statusline.json" CONFIG_DST="$CLAUDE_DIR/statusline.json"
@@ -103,7 +146,8 @@ fi
# ── Done ───────────────────────────────────────────────────────────── # ── Done ─────────────────────────────────────────────────────────────
echo "" echo ""
echo "Done. Restart Claude Code to see the status line." echo "Done. RESTART Claude Code (exit and reopen) to see the status line."
echo "" echo ""
echo "Quick test: $BINARY_NAME --test --color=always" echo "Verify: $BINARY_PATH --test --color=always"
echo "Debug: $BINARY_NAME --test --dump-state=json" echo "Debug: $BINARY_PATH --test --dump-state=json"
echo "Settings: cat $SETTINGS | jq .statusLine"

View File

@@ -188,7 +188,6 @@
"enum": [ "enum": [
"auto", "auto",
"git", "git",
"jj",
"none" "none"
], ],
"description": "VCS detection mode", "description": "VCS detection mode",
@@ -375,16 +374,6 @@
"minimum": 0, "minimum": 0,
"default": 8 "default": 8
}, },
"prefer": {
"type": "string",
"enum": [
"auto",
"git",
"jj"
],
"default": "auto",
"description": "VCS preference. auto detects .jj/ first, then .git/"
},
"show_ahead_behind": { "show_ahead_behind": {
"type": "boolean", "type": "boolean",
"default": true "default": true

View File

@@ -303,41 +303,14 @@ fn prefetch_shell_outs(
std::thread::scope(|s| { std::thread::scope(|s| {
let vcs_handle = if needs_vcs { let vcs_handle = if needs_vcs {
let args: Vec<String> = match vcs_type { Some(s.spawn(move || {
section::VcsType::Git => vec![ shell::exec_gated(
"git".into(), shell_config,
"-C".into(), "git",
project_dir.into(), &["-C", project_dir, "status", "--porcelain=v2", "--branch"],
"status".into(), None,
"--porcelain=v2".into(), )
"--branch".into(), }))
],
section::VcsType::Jj => vec![
"jj".into(),
"log".into(),
"-r".into(),
"@".into(),
"--no-graph".into(),
"-T".into(),
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))".into(),
"--color=never".into(),
],
section::VcsType::None => vec![],
};
if args.is_empty() {
None
} else {
let dir = if vcs_type == section::VcsType::Jj {
Some(project_dir.to_string())
} else {
None
};
Some(s.spawn(move || {
let prog = &args[0];
let str_args: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect();
shell::exec_gated(shell_config, prog, &str_args, dir.as_deref())
}))
}
} else { } else {
None None
}; };
@@ -483,34 +456,12 @@ fn resolve_terminal_palette(cache: &cache::Cache) -> Option<Vec<(u8, u8, u8)>> {
Some(palette) Some(palette)
} }
fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType { fn detect_vcs(dir: &str, _config: &config::Config) -> section::VcsType {
let prefer = config.sections.vcs.prefer.as_str();
let path = std::path::Path::new(dir); let path = std::path::Path::new(dir);
if path.join(".git").is_dir() {
match prefer { section::VcsType::Git
"jj" => { } else {
if path.join(".jj").is_dir() { section::VcsType::None
section::VcsType::Jj
} else {
section::VcsType::None
}
}
"git" => {
if path.join(".git").is_dir() {
section::VcsType::Git
} else {
section::VcsType::None
}
}
_ => {
if path.join(".jj").is_dir() {
section::VcsType::Jj
} else if path.join(".git").is_dir() {
section::VcsType::Git
} else {
section::VcsType::None
}
}
} }
} }

View File

@@ -296,7 +296,6 @@ impl Default for TruncateConfig {
pub struct VcsSection { pub struct VcsSection {
#[serde(flatten)] #[serde(flatten)]
pub base: SectionBase, pub base: SectionBase,
pub prefer: String,
pub show_ahead_behind: bool, pub show_ahead_behind: bool,
pub show_dirty: bool, pub show_dirty: bool,
pub untracked: String, pub untracked: String,
@@ -314,7 +313,6 @@ impl Default for VcsSection {
min_width: Some(8), min_width: Some(8),
..Default::default() ..Default::default()
}, },
prefer: "auto".into(),
show_ahead_behind: true, show_ahead_behind: true,
show_dirty: true, show_dirty: true,
untracked: "normal".into(), untracked: "normal".into(),

View File

@@ -2,11 +2,11 @@ use crate::format;
use crate::layout::ActiveSection; use crate::layout::ActiveSection;
use crate::section::{self, RenderContext}; use crate::section::{self, RenderContext};
/// Expand the winning flex section to fill remaining terminal width. /// Expand flex sections to fill remaining terminal width.
/// ///
/// Rules: /// Rules:
/// - Spacers take priority over non-spacer flex sections /// - If multiple spacers exist, distribute extra space proportionally among them
/// - Only one flex section wins per line /// - If only non-spacer flex sections exist, pick one winner (spacer > non-spacer)
/// - Spacer: fill with spaces /// - Spacer: fill with spaces
/// - context_bar: rebuild bar with wider width /// - context_bar: rebuild bar with wider width
/// - Other: pad with trailing spaces /// - Other: pad with trailing spaces
@@ -18,7 +18,34 @@ pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator:
return; return;
} }
// Find winning flex section: spacer wins over non-spacer let extra = term_width - current_width;
// Collect all spacer indices
let spacer_indices: Vec<usize> = active
.iter()
.enumerate()
.filter(|(_, s)| s.is_spacer)
.map(|(i, _)| i)
.collect();
// If multiple spacers, distribute extra space among them
if spacer_indices.len() > 1 {
let per_spacer = extra / spacer_indices.len();
let remainder = extra % spacer_indices.len();
for (i, &idx) in spacer_indices.iter().enumerate() {
// First spacers get +1 char from remainder
let this_width = per_spacer + if i < remainder { 1 } else { 0 } + 1;
let padding = " ".repeat(this_width);
active[idx].output = section::SectionOutput {
raw: padding.clone(),
ansi: padding,
};
}
return;
}
// Single spacer or non-spacer flex: find winning flex section
let mut flex_idx: Option<usize> = None; let mut flex_idx: Option<usize> = None;
for (i, sec) in active.iter().enumerate() { for (i, sec) in active.iter().enumerate() {
if !sec.is_flex { if !sec.is_flex {
@@ -35,7 +62,6 @@ pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator:
} }
let Some(idx) = flex_idx else { return }; let Some(idx) = flex_idx else { return };
let extra = term_width - current_width;
if active[idx].is_spacer { if active[idx].is_spacer {
let padding = " ".repeat(extra + 1); let padding = " ".repeat(extra + 1);

View File

@@ -52,7 +52,6 @@ pub type RenderFn = fn(&RenderContext) -> Option<SectionOutput>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VcsType { pub enum VcsType {
Git, Git,
Jj,
None, None,
} }

View File

@@ -12,7 +12,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None; return None;
} }
// --no-shell: serve stale cache only, skip all git/jj commands // --no-shell: serve stale cache only, skip all git commands
if ctx.no_shell { if ctx.no_shell {
return render_stale_cache(ctx); return render_stale_cache(ctx);
} }
@@ -21,11 +21,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
let ttl = &ctx.config.sections.vcs.ttl; let ttl = &ctx.config.sections.vcs.ttl;
let glyphs = &ctx.config.glyphs; let glyphs = &ctx.config.glyphs;
match ctx.vcs_type { render_git(ctx, dir, ttl, glyphs)
VcsType::Git => render_git(ctx, dir, ttl, glyphs),
VcsType::Jj => render_jj(ctx, dir, ttl, glyphs),
VcsType::None => None,
}
} }
/// Serve stale cached VCS data without running any commands. /// Serve stale cached VCS data without running any commands.
@@ -158,53 +154,3 @@ fn render_git(
Some(SectionOutput { raw, ansi }) Some(SectionOutput { raw, ansi })
} }
fn render_jj(
ctx: &RenderContext,
dir: &str,
ttl: &crate::config::VcsTtl,
glyphs: &crate::config::GlyphConfig,
) -> Option<SectionOutput> {
use std::time::Duration;
let branch_ttl = Duration::from_secs(ttl.branch);
let branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| {
// Use prefetched result if available, otherwise exec
let out = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
shell::exec_gated(
ctx.shell_config,
"jj",
&[
"log",
"-r",
"@",
"--no-graph",
"-T",
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
"--color=never",
],
Some(dir),
)
})?;
ctx.cache.set("vcs_branch", &out);
Some(out)
})?;
let trunc = &ctx.config.sections.vcs.truncate;
let branch = if trunc.enabled && trunc.max > 0 {
crate::format::truncate(&branch, trunc.max, &trunc.style)
} else {
branch
};
let branch_glyph = glyph::glyph("branch", glyphs);
let raw = format!("{branch_glyph}{branch}");
let ansi = if ctx.color_enabled {
format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET)
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}

View File

@@ -125,7 +125,7 @@ pub fn parse_transcript(path: &Path, skip_lines: usize) -> Option<TranscriptStat
// Sort by count descending // Sort by count descending
let mut sorted: Vec<(String, u64)> = counts.into_iter().collect(); let mut sorted: Vec<(String, u64)> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1)); sorted.sort_by_key(|item| std::cmp::Reverse(item.1));
stats.tool_counts = sorted; stats.tool_counts = sorted;
Some(stats) Some(stats)

View File

@@ -1,28 +1,34 @@
{ {
"$schema": "./schema.json",
"global": { "global": {
"justify": "spread", "justify": "spread",
"theme": "dark", "theme": "dark",
"responsive": true, "responsive": true,
"width": 500, "width": 500,
"width_margin": 20 "width_margin": 45
}, },
"glyphs": { "glyphs": {
"enabled": true "enabled": true
}, },
"layout": "verbose", "layout": [
["model", "provider", "project", "spacer", "context_bar", "context_usage", "spacer", "cost", "cost_velocity"],
["vcs", "lines_changed", "spacer", "tokens_raw", "cache_efficiency", "spacer", "beads"]
],
"sections": { "sections": {
"context_usage": { "context_usage": {
"enabled": true "enabled": true
}, },
"context_trend": { "context_bar": {
"flex": true,
"min_width": 12
},
"tokens_raw": {
"enabled": true "enabled": true
}, },
"cost_trend": { "cache_efficiency": {
"enabled": true "enabled": true
}, },
"load": { "cost_velocity": {
"enabled": false "enabled": true
} }
} }
} }