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/
target/
.DS_Store
src/.DS_Store

View File

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

View File

@@ -31,14 +31,40 @@ echo "[ok] jq found"
# ── Build release binary ─────────────────────────────────────────────
echo ""
echo "Building release binary..."
(cd "$SCRIPT_DIR" && cargo build --release --quiet)
echo "[ok] Built: $(ls -lh "$SCRIPT_DIR/target/release/$BINARY_NAME" | awk '{print $5}')"
if ! (cd "$SCRIPT_DIR" && cargo build --release); then
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 ───────────────────────────────────────────────────
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"
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
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\""
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 ──────────────────────────────
echo ""
mkdir -p "$CLAUDE_DIR"
BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
# The binary runs in a non-TTY context, so force color on.
STATUSLINE_CMD="$BINARY_PATH --color=always"
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)
if [[ -n "$CURRENT_CMD" ]]; then
echo "[info] Current statusLine command: $CURRENT_CMD"
echo "[info] Previous statusLine command: $CURRENT_CMD"
fi
# Write updated settings
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"
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
# Create minimal settings.json
jq -n --arg cmd "$STATUSLINE_CMD" '{"statusLine": {"type": "command", "command": $cmd, "padding": 0}}' > "$SETTINGS"
echo "[ok] Created $SETTINGS"
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 ───────────────────────────────────────────────────
CONFIG_SRC="$SCRIPT_DIR/statusline.json"
CONFIG_DST="$CLAUDE_DIR/statusline.json"
@@ -103,7 +146,8 @@ fi
# ── Done ─────────────────────────────────────────────────────────────
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 "Quick test: $BINARY_NAME --test --color=always"
echo "Debug: $BINARY_NAME --test --dump-state=json"
echo "Verify: $BINARY_PATH --test --color=always"
echo "Debug: $BINARY_PATH --test --dump-state=json"
echo "Settings: cat $SETTINGS | jq .statusLine"

View File

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

View File

@@ -303,41 +303,14 @@ fn prefetch_shell_outs(
std::thread::scope(|s| {
let vcs_handle = if needs_vcs {
let args: Vec<String> = match vcs_type {
section::VcsType::Git => vec![
"git".into(),
"-C".into(),
project_dir.into(),
"status".into(),
"--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())
shell::exec_gated(
shell_config,
"git",
&["-C", project_dir, "status", "--porcelain=v2", "--branch"],
None,
)
}))
}
} else {
None
};
@@ -483,35 +456,13 @@ fn resolve_terminal_palette(cache: &cache::Cache) -> Option<Vec<(u8, u8, u8)>> {
Some(palette)
}
fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType {
let prefer = config.sections.vcs.prefer.as_str();
fn detect_vcs(dir: &str, _config: &config::Config) -> section::VcsType {
let path = std::path::Path::new(dir);
match prefer {
"jj" => {
if path.join(".jj").is_dir() {
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
}
}
}
}
#[allow(clippy::too_many_arguments)]

View File

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

View File

@@ -2,11 +2,11 @@ use crate::format;
use crate::layout::ActiveSection;
use crate::section::{self, RenderContext};
/// Expand the winning flex section to fill remaining terminal width.
/// Expand flex sections to fill remaining terminal width.
///
/// Rules:
/// - Spacers take priority over non-spacer flex sections
/// - Only one flex section wins per line
/// - If multiple spacers exist, distribute extra space proportionally among them
/// - If only non-spacer flex sections exist, pick one winner (spacer > non-spacer)
/// - Spacer: fill with spaces
/// - context_bar: rebuild bar with wider width
/// - Other: pad with trailing spaces
@@ -18,7 +18,34 @@ pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator:
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;
for (i, sec) in active.iter().enumerate() {
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 extra = term_width - current_width;
if active[idx].is_spacer {
let padding = " ".repeat(extra + 1);

View File

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

View File

@@ -12,7 +12,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
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 {
return render_stale_cache(ctx);
}
@@ -21,11 +21,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
let ttl = &ctx.config.sections.vcs.ttl;
let glyphs = &ctx.config.glyphs;
match ctx.vcs_type {
VcsType::Git => render_git(ctx, dir, ttl, glyphs),
VcsType::Jj => render_jj(ctx, dir, ttl, glyphs),
VcsType::None => None,
}
render_git(ctx, dir, ttl, glyphs)
}
/// Serve stale cached VCS data without running any commands.
@@ -158,53 +154,3 @@ fn render_git(
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
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;
Some(stats)

View File

@@ -1,28 +1,34 @@
{
"$schema": "./schema.json",
"global": {
"justify": "spread",
"theme": "dark",
"responsive": true,
"width": 500,
"width_margin": 20
"width_margin": 45
},
"glyphs": {
"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": {
"context_usage": {
"enabled": true
},
"context_trend": {
"context_bar": {
"flex": true,
"min_width": 12
},
"tokens_raw": {
"enabled": true
},
"cost_trend": {
"cache_efficiency": {
"enabled": true
},
"load": {
"enabled": false
"cost_velocity": {
"enabled": true
}
}
}