Display shortest unique key prefix in output
Keys now show 8 characters with the unique prefix bolded and the
remainder dimmed, matching jj's change ID style. Users can see
at a glance how much of a key they need to type.

Assisted-by: Claude Opus 4.6 via pi
change uxpmtxrkmuxkvvtpmvnmmomrmtluovut
commit d0093333cd033569c7a13d0efcacb4e85fa45f87
author Alpha Chen <alpha@kejadlen.dev>
date
parent zpsuzlnk
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 312335b..7874ef4 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -1,6 +1,7 @@
 use clap::Subcommand;
 use color_eyre::eyre::Result;
 use ranger::db::SqlitePool;
+use ranger::key;
 use ranger::models::{Backlog, State};
 use ranger::ops;
 
@@ -67,6 +68,9 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
                 });
                 println!("{}", serde_json::to_string_pretty(&detail).unwrap());
             } else {
+                let all_keys = ops::task::all_keys(&mut conn).await?;
+                let prefixes = key::unique_prefix_lengths(&all_keys);
+
                 print_backlog_detail(&backlog);
 
                 for state in [State::Done, State::InProgress, State::Queued, State::Icebox] {
@@ -74,7 +78,11 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
                     if !tasks.is_empty() {
                         println!("\n[{}]", state);
                         for t in &tasks {
-                            println!("  {} {}", &t.key[..8], t.title);
+                            println!(
+                                "  {} {}",
+                                output::format_key_from_map(&t.key, &prefixes),
+                                t.title
+                            );
                         }
                     }
                 }
diff --git a/src/bin/ranger/commands/blocker.rs b/src/bin/ranger/commands/blocker.rs
index 1842f3a..38781c9 100644
--- a/src/bin/ranger/commands/blocker.rs
+++ b/src/bin/ranger/commands/blocker.rs
@@ -1,6 +1,7 @@
 use clap::Subcommand;
 use color_eyre::eyre::Result;
 use ranger::db::SqlitePool;
+use ranger::key;
 use ranger::ops;
 
 use crate::output;
@@ -32,16 +33,29 @@ pub async fn run(pool: &SqlitePool, command: BlockerCommands, json: bool) -> Res
         BlockerCommands::Add { task, blocked_by } => {
             let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
             let bt = ops::task::get_by_key_prefix(&mut conn, &blocked_by).await?;
+            let all_keys = ops::task::all_keys(&mut conn).await?;
+            let prefixes = key::unique_prefix_lengths(&all_keys);
             let blocker = ops::blocker::add(&mut conn, t.id, bt.id).await?;
             output::print(&blocker, json, |_| {
-                println!("{} blocked by {} {}", &t.key[..8], &bt.key[..8], bt.title);
+                println!(
+                    "{} blocked by {} {}",
+                    output::format_key_from_map(&t.key, &prefixes),
+                    output::format_key_from_map(&bt.key, &prefixes),
+                    bt.title
+                );
             });
         }
         BlockerCommands::Remove { task, blocked_by } => {
             let t = ops::task::get_by_key_prefix(&mut conn, &task).await?;
             let bt = ops::task::get_by_key_prefix(&mut conn, &blocked_by).await?;
+            let all_keys = ops::task::all_keys(&mut conn).await?;
+            let prefixes = key::unique_prefix_lengths(&all_keys);
             ops::blocker::remove(&mut conn, t.id, bt.id).await?;
-            println!("Removed blocker {} from {}", &bt.key[..8], &t.key[..8]);
+            println!(
+                "Removed blocker {} from {}",
+                output::format_key_from_map(&bt.key, &prefixes),
+                output::format_key_from_map(&t.key, &prefixes)
+            );
         }
     }
     Ok(())
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index f863c9d..ba72eff 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -1,6 +1,9 @@
+use std::collections::HashMap;
+
 use clap::{Args, Subcommand};
 use color_eyre::eyre::{Result, bail};
 use ranger::db::{SqliteConnection, SqlitePool};
+use ranger::key;
 use ranger::models::{State, Task};
 use ranger::ops;
 use ranger::ops::task::Placement;
@@ -177,16 +180,22 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
 
             tx.commit().await?;
 
-            output::print(&task, json, print_task);
+            let mut conn = pool.acquire().await?;
+            let all_keys = ops::task::all_keys(&mut conn).await?;
+            let prefixes = key::unique_prefix_lengths(&all_keys);
+            output::print(&task, json, |t| print_task(t, &prefixes));
         }
         TaskCommands::List { backlog, state } => {
             let mut conn = pool.acquire().await?;
             let state = state.map(|s| s.parse::<State>()).transpose()?;
 
+            let all_keys = ops::task::all_keys(&mut conn).await?;
+            let prefixes = key::unique_prefix_lengths(&all_keys);
+
             if let Some(backlog_name) = &backlog {
                 let bl = ops::backlog::get_by_name(&mut conn, backlog_name).await?;
                 let tasks = ops::task::list(&mut conn, bl.id, state).await?;
-                output::print_list(&tasks, json, print_task);
+                output::print_list(&tasks, json, |t| print_task(t, &prefixes));
             } else {
                 // List all tasks (no backlog filter)
                 let backlogs = ops::backlog::list(&mut conn).await?;
@@ -199,7 +208,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                         }
                     }
                 }
-                output::print_list(&all_tasks, json, print_task);
+                output::print_list(&all_tasks, json, |t| print_task(t, &prefixes));
             }
         }
         TaskCommands::Show { key } => {
@@ -218,7 +227,10 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 });
                 println!("{}", serde_json::to_string_pretty(&detail).unwrap());
             } else {
-                print_task_detail(&task);
+                let all_keys = ops::task::all_keys(&mut conn).await?;
+                let prefixes = key::unique_prefix_lengths(&all_keys);
+
+                print_task_detail(&task, &prefixes);
                 if !tags.is_empty() {
                     let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
                     println!("Tags:    {}", tag_names.join(", "));
@@ -228,7 +240,11 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                     for b in &blockers {
                         if let Ok(bt) = ops::task::get_by_id(&mut conn, b.blocked_by_task_id).await
                         {
-                            println!("  {} {}", &bt.key[..8], bt.title);
+                            println!(
+                                "  {} {}",
+                                output::format_key_from_map(&bt.key, &prefixes),
+                                bt.title
+                            );
                         }
                     }
                 }
@@ -266,7 +282,9 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
                 ops::task::move_task(&mut conn, &updated, anchors.as_placement()).await?;
             }
 
-            output::print(&updated, json, print_task);
+            let all_keys = ops::task::all_keys(&mut conn).await?;
+            let prefixes = key::unique_prefix_lengths(&all_keys);
+            output::print(&updated, json, |t| print_task(t, &prefixes));
         }
         TaskCommands::Move { key, position } => {
             let mut conn = pool.acquire().await?;
@@ -276,7 +294,13 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
             match anchors {
                 Some(anchors) => {
                     ops::task::move_task(&mut conn, &task, anchors.as_placement()).await?;
-                    println!("Moved {} {}", &task.key[..8], task.title);
+                    let all_keys = ops::task::all_keys(&mut conn).await?;
+                    let prefixes = key::unique_prefix_lengths(&all_keys);
+                    println!(
+                        "Moved {} {}",
+                        output::format_key_from_map(&task.key, &prefixes),
+                        task.title
+                    );
                 }
                 None => bail!("--before or --after is required"),
             }
@@ -284,19 +308,30 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
         TaskCommands::Delete { key } => {
             let mut conn = pool.acquire().await?;
             let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+            let all_keys = ops::task::all_keys(&mut conn).await?;
+            let prefixes = key::unique_prefix_lengths(&all_keys);
             ops::task::delete(&mut conn, task.id).await?;
-            println!("Deleted {} {}", &task.key[..8], task.title);
+            println!(
+                "Deleted {} {}",
+                output::format_key_from_map(&task.key, &prefixes),
+                task.title
+            );
         }
     }
     Ok(())
 }
 
-fn print_task(t: &Task) {
-    println!("{} [{}] {}", &t.key[..8], t.state, t.title);
+fn print_task(t: &Task, prefixes: &HashMap<String, usize>) {
+    println!(
+        "{} [{}] {}",
+        output::format_key_from_map(&t.key, prefixes),
+        t.state,
+        t.title
+    );
 }
 
-fn print_task_detail(t: &Task) {
-    println!("Key:     {}", t.key);
+fn print_task_detail(t: &Task, prefixes: &HashMap<String, usize>) {
+    println!("Key:     {}", output::format_key_from_map(&t.key, prefixes));
     println!("Title:   {}", t.title);
     println!("State:   {}", t.state);
     if let Some(desc) = &t.description {
diff --git a/src/bin/ranger/output.rs b/src/bin/ranger/output.rs
index 0b6719b..98cfe06 100644
--- a/src/bin/ranger/output.rs
+++ b/src/bin/ranger/output.rs
@@ -1,4 +1,5 @@
 use serde::Serialize;
+use std::collections::HashMap;
 
 pub fn print<T: Serialize + std::fmt::Debug>(value: &T, json: bool, human: impl FnOnce(&T)) {
     if json {
@@ -17,3 +18,22 @@ pub fn print_list<T: Serialize + std::fmt::Debug>(values: &[T], json: bool, huma
         }
     }
 }
+
+/// The number of key characters to display (matching jj's short change ID style).
+const DISPLAY_LEN: usize = 8;
+
+/// Format a key for display: unique prefix in bold, remainder (up to DISPLAY_LEN) in dim.
+/// If the terminal doesn't support colors, returns the plain 8-char prefix.
+pub fn format_key(key: &str, prefix_len: usize) -> String {
+    let show = &key[..DISPLAY_LEN.min(key.len())];
+    let unique = prefix_len.min(show.len());
+    let bold = &show[..unique];
+    let dim = &show[unique..];
+    format!("\x1b[1m{bold}\x1b[0m\x1b[2m{dim}\x1b[0m")
+}
+
+/// Shorthand: format a key when you only have a single key and its prefix length.
+pub fn format_key_from_map(key: &str, prefix_lengths: &HashMap<String, usize>) -> String {
+    let len = prefix_lengths.get(key).copied().unwrap_or(DISPLAY_LEN);
+    format_key(key, len)
+}
diff --git a/src/key.rs b/src/key.rs
index d048554..bdbba7a 100644
--- a/src/key.rs
+++ b/src/key.rs
@@ -18,6 +18,28 @@ pub fn generate_key() -> String {
         .collect()
 }
 
+/// Returns the minimum prefix length needed to uniquely identify `key` among `all_keys`.
+/// The minimum returned value is 1 (even if the set has only one key).
+pub fn shortest_unique_prefix_len(key: &str, all_keys: &[String]) -> usize {
+    let mut len = 1;
+    while len < key.len() {
+        let prefix = &key[..len];
+        let count = all_keys.iter().filter(|k| k.starts_with(prefix)).count();
+        if count <= 1 {
+            return len;
+        }
+        len += 1;
+    }
+    unreachable!("all keys are the same length") // cov-excl-line
+}
+
+/// Builds a map from key → shortest unique prefix length for all keys in the set.
+pub fn unique_prefix_lengths(keys: &[String]) -> std::collections::HashMap<String, usize> {
+    keys.iter()
+        .map(|k| (k.clone(), shortest_unique_prefix_len(k, keys)))
+        .collect()
+}
+
 pub fn resolve_prefix(prefix: &str, keys: &[String]) -> Result<String, RangerError> {
     let matches: Vec<&String> = keys.iter().filter(|k| k.starts_with(prefix)).collect();
     match matches.len() {
@@ -55,6 +77,46 @@ mod tests {
         assert_eq!(keys.len(), unique.len());
     }
 
+    #[test]
+    fn shortest_unique_prefix_single_key() {
+        let keys = vec!["romoqtuw".to_string()];
+        assert_eq!(shortest_unique_prefix_len("romoqtuw", &keys), 1);
+    }
+
+    #[test]
+    fn shortest_unique_prefix_diverges_at_second_char() {
+        let keys = vec!["romoqtuw".to_string(), "rypqxnkl".to_string()];
+        // Both start with 'r', diverge at char 2
+        assert_eq!(shortest_unique_prefix_len("romoqtuw", &keys), 2);
+        assert_eq!(shortest_unique_prefix_len("rypqxnkl", &keys), 2);
+    }
+
+    #[test]
+    fn shortest_unique_prefix_longer_shared() {
+        let keys = vec![
+            "romoqtuw".to_string(),
+            "romxnklp".to_string(),
+            "rypqxnkl".to_string(),
+        ];
+        // "romo" vs "romx" need 4 chars, "ry" needs 2
+        assert_eq!(shortest_unique_prefix_len("romoqtuw", &keys), 4);
+        assert_eq!(shortest_unique_prefix_len("romxnklp", &keys), 4);
+        assert_eq!(shortest_unique_prefix_len("rypqxnkl", &keys), 2);
+    }
+
+    #[test]
+    fn unique_prefix_lengths_builds_map() {
+        let keys = vec![
+            "romoqtuw".to_string(),
+            "rypqxnkl".to_string(),
+            "slmnopqr".to_string(),
+        ];
+        let map = unique_prefix_lengths(&keys);
+        assert_eq!(map["romoqtuw"], 2);
+        assert_eq!(map["rypqxnkl"], 2);
+        assert_eq!(map["slmnopqr"], 1);
+    }
+
     #[test]
     fn resolve_prefix_exact_match() {
         let keys = vec!["romoqtuw".to_string(), "rypqxnkl".to_string()];
diff --git a/src/ops/task.rs b/src/ops/task.rs
index d255af3..2e881df 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -83,6 +83,14 @@ pub async fn list(
     Ok(tasks)
 }
 
+/// Fetch all task keys in the database. Used to compute shortest unique prefixes.
+pub async fn all_keys(conn: &mut SqliteConnection) -> Result<Vec<String>, RangerError> {
+    let rows: Vec<(String,)> = sqlx::query_as("SELECT key FROM tasks")
+        .fetch_all(&mut *conn)
+        .await?;
+    Ok(rows.into_iter().map(|(k,)| k).collect())
+}
+
 pub async fn get_by_id(conn: &mut SqliteConnection, id: i64) -> Result<Task, RangerError> {
     let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?");
     let task = sqlx::query_as::<_, Task>(&query)