Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Simplify & improve CLI output
Alexis Sellier committed 3 years ago
commit 0a93fa833ebbf650d7cd284f13b353eee393cc54
parent 11da344e3a04ed20ca527945e2c832addd4facf0
44 files changed +1995 -599
modified Cargo.lock
@@ -403,19 +403,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a90734b3d5dcf656e7624cca6bce9c3a90ee11f900e80141a7427ccfb3d317"

[[package]]
-
name = "console"
-
version = "0.15.5"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c3d79fbe8970a77e3e34151cc13d3b3e248aa0faaecb9f6091fa07ebefe5ad60"
-
dependencies = [
-
 "encode_unicode",
-
 "lazy_static",
-
 "libc",
-
 "unicode-width",
-
 "windows-sys",
-
]
-

-
[[package]]
name = "const-oid"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -644,18 +631,6 @@ dependencies = [
]

[[package]]
-
name = "dialoguer"
-
version = "0.10.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "af3c796f3b0b408d9fd581611b47fa850821fcb84aa640b83a3c1a5be2d691f2"
-
dependencies = [
-
 "console",
-
 "shell-words",
-
 "tempfile",
-
 "zeroize",
-
]
-

-
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -683,6 +658,12 @@ dependencies = [
]

[[package]]
+
name = "dyn-clone"
+
version = "1.0.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c9b0705efd4599c15a38151f4721f7bc388306f61084d3bfd50bd07fbca5cb60"
+

+
[[package]]
name = "ec25519"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -758,12 +739,6 @@ dependencies = [
]

[[package]]
-
name = "encode_unicode"
-
version = "0.3.6"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
-

-
[[package]]
name = "errno"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1174,24 +1149,29 @@ dependencies = [
]

[[package]]
-
name = "indicatif"
-
version = "0.16.2"
+
name = "inout"
+
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b"
+
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
-
 "console",
-
 "lazy_static",
-
 "number_prefix",
-
 "regex",
+
 "generic-array",
]

[[package]]
-
name = "inout"
-
version = "0.1.3"
+
name = "inquire"
+
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+
checksum = "f3a94f0659efe59329832ba0452d3ec753145fc1fb12a8e1d60de4ccf99f5364"
dependencies = [
-
 "generic-array",
+
 "bitflags",
+
 "dyn-clone",
+
 "lazy_static",
+
 "newline-converter",
+
 "tempfile",
+
 "termion 1.5.6",
+
 "thiserror",
+
 "unicode-segmentation",
+
 "unicode-width",
]

[[package]]
@@ -1448,6 +1428,15 @@ dependencies = [
]

[[package]]
+
name = "newline-converter"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f"
+
dependencies = [
+
 "unicode-segmentation",
+
]
+

+
[[package]]
name = "noise-framework"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1558,10 +1547,10 @@ dependencies = [
]

[[package]]
-
name = "number_prefix"
-
version = "0.4.0"
+
name = "numtoa"
+
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"

[[package]]
name = "once_cell"
@@ -1833,12 +1822,12 @@ version = "0.8.0"
dependencies = [
 "anyhow",
 "chrono",
-
 "console",
-
 "dialoguer",
-
 "indicatif",
+
 "concolor",
+
 "inquire",
 "json-color",
 "lexopt",
 "log",
+
 "once_cell",
 "pretty_assertions",
 "radicle",
 "radicle-cli-test",
@@ -1850,8 +1839,11 @@ dependencies = [
 "serde_yaml",
 "similar",
 "tempfile",
+
 "termion 2.0.1",
 "thiserror",
 "timeago",
+
 "unicode-segmentation",
+
 "unicode-width",
 "ureq",
 "zeroize",
]
@@ -2064,6 +2056,7 @@ dependencies = [
 "anyhow",
 "git-ref-format",
 "radicle",
+
 "radicle-cli",
]

[[package]]
@@ -2147,6 +2140,15 @@ dependencies = [
]

[[package]]
+
name = "redox_termios"
+
version = "0.1.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f"
+
dependencies = [
+
 "redox_syscall",
+
]
+

+
[[package]]
name = "regex"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2398,12 +2400,6 @@ dependencies = [
]

[[package]]
-
name = "shell-words"
-
version = "1.1.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
-

-
[[package]]
name = "shlex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2633,6 +2629,30 @@ dependencies = [
]

[[package]]
+
name = "termion"
+
version = "1.5.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e"
+
dependencies = [
+
 "libc",
+
 "numtoa",
+
 "redox_syscall",
+
 "redox_termios",
+
]
+

+
[[package]]
+
name = "termion"
+
version = "2.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "659c1f379f3408c7e5e84c7d0da6d93404e3800b6b9d063ba24436419302ec90"
+
dependencies = [
+
 "libc",
+
 "numtoa",
+
 "redox_syscall",
+
 "redox_termios",
+
]
+

+
[[package]]
name = "thiserror"
version = "1.0.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2920,6 +2940,12 @@ dependencies = [
]

[[package]]
+
name = "unicode-segmentation"
+
version = "1.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
+

+
[[package]]
name = "unicode-width"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified radicle-cli-test/src/lib.rs
@@ -9,7 +9,7 @@ use snapbox::{Assert, Substitutions};
use thiserror::Error;

/// Error lines in the CLI are prefixed with this string.
-
const ERROR_PREFIX: &str = "==";
+
const ERROR_PREFIX: &str = "✗";

#[derive(Error, Debug)]
pub enum Error {
modified radicle-cli/Cargo.toml
@@ -13,18 +13,21 @@ path = "src/main.rs"
[dependencies]
anyhow = { version = "1" }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
-
console = { version = "0.15" }
-
dialoguer = { version = "0.10.0" }
-
indicatif = { version = "0.16.2" }
+
concolor = { version = "0", features = ["api_unstable"] }
+
inquire = { version = "0.5.3", default-features = false, features = ["termion", "editor"] }
json-color = { version = "0.7" }
lexopt = { version = "0.2" }
log = { version = "0.4", features = ["std"] }
+
once_cell = { version = "1.13" }
serde = { version = "1.0" }
serde_json = { version = "1" }
serde_yaml = { version = "0.8" }
similar = { version = "2.2.1" }
+
termion = { version = "2" }
thiserror = { version = "1" }
timeago = { version = "0.3", default-features = false }
+
unicode-width = { version = "0.1.10", default-features = false }
+
unicode-segmentation = { version = "1.7.1" }
ureq = { version = "2.6.1", default-features = false, features = ["json"] }
zeroize = { version = "1.1" }

@@ -46,6 +49,6 @@ path = "../radicle-crypto"

[dev-dependencies]
pretty_assertions = { version = "1.3.0" }
-
tempfile = { version = "3.3.0" }
radicle = { path = "../radicle", features = ["test"] }
radicle-node = { path = "../radicle-node", features = ["test"] }
+
tempfile = { version = "3.3.0" }
modified radicle-cli/examples/rad-auth.md
@@ -6,12 +6,10 @@ $ rad auth

Initializing your 🌱 profile and identity

-
ok Creating your 🌱 Ed25519 keypair...
-
ok Profile did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created.
+
✓ Creating your 🌱 Ed25519 keypair...
+
✓ Your Radicle ID is did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi. This identifies your device.

-
Your radicle Node ID is z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi. This identifies your device.
-

-
=> To create a radicle project, run `rad init` from a git repository.
+
👉 To create a radicle project, run `rad init` from a git repository.
```

You can get the above information at all times using the `self` command:
modified radicle-cli/examples/rad-checkout.md
@@ -6,7 +6,7 @@ $ rad checkout rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji

Initializing local checkout for 🌱 rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji (heartwood)

-
ok Performing checkout...
+
✓ Performing checkout...

🌱 Project checkout successful under ./heartwood

modified radicle-cli/examples/rad-clone-unknown.md
@@ -2,6 +2,6 @@ Trying to clone a repository that is not in our routing table returns an error:

```
$ rad clone rad:zVNuptPuk5XauitpCWSNVCXGGfXW
-
ok Tracking relationship established for rad:zVNuptPuk5XauitpCWSNVCXGGfXW
-
== Clone failed no seeds found for rad:zVNuptPuk5XauitpCWSNVCXGGfXW
+
✓ Tracking relationship established for rad:zVNuptPuk5XauitpCWSNVCXGGfXW
+
✗ Clone failed: no seeds found for rad:zVNuptPuk5XauitpCWSNVCXGGfXW
```
modified radicle-cli/examples/rad-clone.md
@@ -3,12 +3,12 @@ To create a local copy of a repository on the radicle network, we use the

```
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
ok Tracking relationship established for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
ok Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi..
-
ok Forking under z6Mkt67…v4N1tRk..
-
ok Creating checkout in ./heartwood..
-
ok Remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created
-
ok Remote-tracking branch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSL…StBU8Vi
+
✓ Tracking relationship established for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi..
+
✓ Forking under z6Mkt67…v4N1tRk..
+
✓ Creating checkout in ./heartwood..
+
✓ Remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created
+
✓ Remote-tracking branch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSL…StBU8Vi

🌱 Project successfully cloned under [..]/heartwood/

modified radicle-cli/examples/rad-delegate.md
@@ -16,7 +16,7 @@ work.
```
$ rad delegate add did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG'
-
ok Update successful!
+
✓ Update successful!
```

Let's convince ourselves that there's another delegate.
@@ -35,7 +35,7 @@ the torch and remove ourselves from the delegate set.
```
$ rad delegate remove did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
-
ok Update successful!
+
✓ Update successful!
```

```
modified radicle-cli/examples/rad-id-rebase.md
@@ -6,10 +6,10 @@ delegates creating proposals concurrently.

```
$ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
ok Identity proposal '57332790a2eabc0b2fd8c7ff48c3579d5812d405' created 🌱
+
✓ Identity proposal '57332790a2eabc0b2fd8c7ff48c3579d5812d405' created 🌱
title: Add Alice
description: Add Alice as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -43,15 +43,15 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

```
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm
-
ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' created 🌱
+
✓ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' created 🌱
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -85,7 +85,7 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

Now, if the first proposal was accepted and committed before the
@@ -94,10 +94,10 @@ through that and see what happens.

```
$ rad id accept 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
ok Accepted proposal ✓
+
✓ Accepted proposal ✓
title: Add Alice
description: Add Alice as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -133,15 +133,15 @@ keys: []

Quorum Reached

-
✓ yes
+
👍 yes
```

```
$ rad id commit 57332790a2eabc0b2fd8c7ff48c3579d5812d405 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
ok Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱
+
✓ Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e' 🌱
title: Add Alice
description: Add Alice as a delegate
-
status:  committed 
+
status: ❲committed❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -177,20 +177,20 @@ keys: []

Quorum Reached

-
✓ yes
+
👍 yes
```

Now, when we go to accept the second proposal:

```
$ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
** Warning: Revision is out of date
-
** Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
-
=> Consider using 'rad id rebase' to update the proposal to the latest identity
-
ok Accepted proposal ✓
+
! Warning: Revision is out of date
+
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
+
👉 Consider using 'rad id rebase' to update the proposal to the latest identity
+
✓ Accepted proposal ✓
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -226,7 +226,7 @@ keys: []

Quorum Reached

-
✓ yes
+
👍 yes
```

Note that a warning was emitted:
@@ -239,22 +239,21 @@ If we attempt to commit this revision, the command will fail:

```
$ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
** Warning: Revision is out of date
-
** Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
-
=> Consider using 'rad id rebase' to update the proposal to the latest identity
-
== Id failed
-
the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1'
+
! Warning: Revision is out of date
+
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
+
👉 Consider using 'rad id rebase' to update the proposal to the latest identity
+
✗ Id failed: the identity hashes do match 'd96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f' for the revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1'
```

So, let's fix this by running a rebase on the proposal's revision:

```
$ rad id rebase c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' rebased 🌱
-
ok Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4'
+
✓ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' rebased 🌱
+
✓ Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4'
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -288,18 +287,18 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

We can now update the proposal to have both keys in the delegates set:

```
$ rad id update c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/4 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
ok Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' updated 🌱
-
ok Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6'
+
✓ Identity proposal 'c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e' updated 🌱
+
✓ Revision 'z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6'
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -333,7 +332,7 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

Finally, we can accept and commit this proposal, creating the final
@@ -343,10 +342,10 @@ $ rad id show c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --revisions

```
$ rad id accept c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm
-
ok Accepted proposal ✓
+
✓ Accepted proposal ✓
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -382,15 +381,15 @@ keys: []

Quorum Reached

-
✓ yes
+
👍 yes
```

```
$ rad id commit c3698d4e85f9d4c0ee536b34d6122fc7c81f7e2e --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/6 --no-confirm
-
ok Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱
+
✓ Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f' 🌱
title: Add Bob
description: Add Bob as a delegate
-
status:  committed 
+
status: ❲committed❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -426,5 +425,5 @@ keys: []

Quorum Reached

-
✓ yes
+
👍 yes
```
modified radicle-cli/examples/rad-id.md
@@ -14,10 +14,10 @@ Let's add Bob as a delegate using their DID

```
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
ok Identity proposal '06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39' created 🌱
+
✓ Identity proposal '06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39' created 🌱
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -51,7 +51,7 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

Before moving on, let's take a few notes on this output. The first
@@ -84,16 +84,16 @@ Finally, we can see whether the `Quorum` was reached:

    Quorum Reached

-
    ✗ no
+
    👎 no

Let's see what happens when we reject the change:

```
$ rad id reject 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
ok Rejected proposal ✗
+
✓ Rejected proposal 👎
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -129,7 +129,7 @@ keys: [

Quorum Reached

-
✗ no
+
👎 no
```

Our key was added to the `Rejected` set of `keys` and the `total`
@@ -146,10 +146,10 @@ Instead, let's accept the proposal:

```
$ rad id accept 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
ok Accepted proposal ✓
+
✓ Accepted proposal ✓
title: Add Bob
description: Add Bob as a delegate
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -185,7 +185,7 @@ keys: []

Quorum Reached

-
✓ yes
+
👍 yes
```

Our key has changed from the `Rejected` set to the `Accepted` set
@@ -202,16 +202,16 @@ As well as that, the `Quorum` has now been reached:

    Quorum Reached

-
    ✓ yes
+
    👍 yes

At this point, we can commit the proposal and update the identity:

```
$ rad id commit 06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 --rev z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/1 --no-confirm
-
ok Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱
+
✓ Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0' 🌱
title: Add Bob
description: Add Bob as a delegate
-
status:  committed 
+
status: ❲committed❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -247,7 +247,7 @@ keys: []

Quorum Reached

-
✓ yes
+
👍 yes
```

Let's say we decide to also change the `threshold`, we can do so using
@@ -255,10 +255,10 @@ the `--threshold` option:

```
$ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm
-
ok Identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' created 🌱
+
✓ Identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810' created 🌱
title: Update threshold
description: Update to safer threshold
-
status:  open 
+
status: ❲open❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -292,17 +292,17 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

But we change our minds and decide to close the proposal instead:

```
$ rad id close dc00640d3152ea5f1df59f39f2f5983d2ad21810 --no-confirm
-
ok Closed identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810'
+
✓ Closed identity proposal 'dc00640d3152ea5f1df59f39f2f5983d2ad21810'
title: Update threshold
description: Update to safer threshold
-
status:  closed 
+
status: ❲closed❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -336,7 +336,7 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

The proposal is now closed and cannot be committed. If at a later date
@@ -348,8 +348,8 @@ Radicle identity, then we can use the list command:

```
$ rad id list
-
06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 "Add Bob"           committed
-
dc00640d3152ea5f1df59f39f2f5983d2ad21810 "Update threshold"  closed
+
06d9efa2a9aad06bfdf25a25690e1ec7db2c3c39 "Add Bob"          ❲committed❳
+
dc00640d3152ea5f1df59f39f2f5983d2ad21810 "Update threshold" ❲closed❳
```

And if we want to view the latest state of any proposal we can use the
@@ -359,7 +359,7 @@ show command:
$ rad id show dc00640d3152ea5f1df59f39f2f5983d2ad21810
title: Update threshold
description: Update to safer threshold
-
status:  closed 
+
status: ❲closed❳
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Document Diff
@@ -393,7 +393,7 @@ keys: []

Quorum Reached

-
✗ no
+
👎 no
```

On a final note, these examples used `--no-confirm`. The default mode
modified radicle-cli/examples/rad-init-sync.md
@@ -7,13 +7,13 @@ $ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" -

Initializing local 🌱 project in .

-
ok Project heartwood created
+
✓ Project heartwood created
{
  "name": "heartwood",
  "description": "Radicle Heartwood Protocol & Stack",
  "defaultBranch": "master"
}
-
ok Syncing inventory..
+
✓ Syncing inventory..

Your project id is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji. You can show it any time by running:
    rad .
modified radicle-cli/examples/rad-init.md
@@ -7,7 +7,7 @@ $ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" -

Initializing local 🌱 project in .

-
ok Project heartwood created
+
✓ Project heartwood created
{
  "name": "heartwood",
  "description": "Radicle Heartwood Protocol & Stack",
modified radicle-cli/examples/rad-patch.md
@@ -29,8 +29,8 @@ $ rad patch open --message "define power requirements" --no-confirm

🌱 Creating patch for heartwood

-
ok Pushing HEAD to storage...
-
ok Analyzing remotes...
+
✓ Pushing HEAD to storage...
+
✓ Analyzing remotes...

z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master (f2de534) <- z6MknSL…StBU8Vi/flux-capacitor-power (3e674d1)
1 commit(s) ahead, 0 commit(s) behind
@@ -45,7 +45,7 @@ No description provided.
╰───────────────────────────────────


-
ok Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 created 🌱
+
✓ Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 created 🌱
```

It will now be listed as one of the project's open patches.
@@ -53,13 +53,13 @@ It will now be listed as one of the project's open patches.
```
$ rad patch

-
- YOU PROPOSED -
+
❲YOU PROPOSED❳

define power requirements d4ef85f57a8 R0 3e674d1 (flux-capacitor-power) ahead 1, behind 0
└─ * opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [..]
└─ * patch id d4ef85f57a849bd845915d7a66a2192cd23811f6

-
- OTHERS PROPOSED -
+
❲OTHERS PROPOSED❳

Nothing to show.

@@ -98,14 +98,14 @@ $ rad patch update --message "Add README, just for the fun" --no-confirm d4ef85f

🌱 Updating patch for heartwood

-
ok Pushing HEAD to storage...
-
ok Analyzing remotes...
+
✓ Pushing HEAD to storage...
+
✓ Analyzing remotes...

d4ef85f57a8 R0 (3e674d1) -> R1 (27857ec)
1 commit(s) ahead, 0 commit(s) behind


-
ok Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 updated 🌱
+
✓ Patch d4ef85f57a849bd845915d7a66a2192cd23811f6 updated 🌱

```

@@ -122,6 +122,6 @@ Now, let's checkout the patch that we just created:

```
$ rad patch checkout d4ef85f57a849bd845915d7a66a2192cd23811f6
-
ok Performing patch checkout...
-
ok Switched to branch patch/d4ef85f57a8
+
✓ Performing patch checkout...
+
✓ Switched to branch patch/d4ef85f57a8
```
modified radicle-cli/examples/rad-rm.md
@@ -10,9 +10,9 @@ Now let's delete the `heartwood` project:

```
$ rad rm rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --no-confirm
-
** Warning: Failed to untrack repository: failed to connect to node: No such file or directory (os error 2)
-
** Warning: Make sure to untrack this repository when your node is running
-
ok Successfully removed project rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from storage
+
! Warning: Failed to untrack repository: failed to connect to node: No such file or directory (os error 2)
+
! Warning: Make sure to untrack this repository when your node is running
+
✓ Successfully removed project rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from storage
```

We can check our repositories again to see if it was deleted:
modified radicle-cli/src/commands/auth.rs
@@ -80,20 +80,18 @@ pub fn init(options: Options) -> anyhow::Result<()> {
    }

    let home = profile::home()?;
-
    let passphrase = term::read_passphrase(options.stdin, true)?;
+
    let passphrase = if options.stdin {
+
        term::passphrase_stdin()
+
    } else {
+
        term::passphrase_confirm()
+
    }?;
    let spinner = term::spinner("Creating your 🌱 Ed25519 keypair...");
    let profile = Profile::init(home, passphrase)?;
    spinner.finish();

    term::success!(
-
        "Profile {} created.",
-
        term::format::highlight(profile.did().to_string())
-
    );
-

-
    term::blank();
-
    term::info!(
-
        "Your radicle Node ID is {}. This identifies your device.",
-
        term::format::highlight(profile.id().to_string())
+
        "Your Radicle ID is {}. This identifies your device.",
+
        term::format::highlight(profile.did())
    );

    term::blank();
@@ -108,6 +106,7 @@ pub fn init(options: Options) -> anyhow::Result<()> {
pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> {
    let agent = ssh::agent::Agent::connect()?;

+
    // TODO: Only show this if we're not authenticated.
    term::headline(&format!(
        "🌱 Authenticating as {}",
        term::format::Identity::new(profile).styled()
@@ -119,7 +118,11 @@ pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> {

        // TODO: We should show the spinner on the passphrase prompt,
        // otherwise it seems like the passphrase is valid even if it isn't.
-
        let passphrase = term::read_passphrase(options.stdin, false)?;
+
        let passphrase = if options.stdin {
+
            term::passphrase_stdin()
+
        } else {
+
            term::passphrase()
+
        }?;
        let spinner = term::spinner("Unlocking...");
        let mut agent = ssh::agent::Agent::connect()?;
        let secret = profile
modified radicle-cli/src/commands/checkout.rs
@@ -97,7 +97,7 @@ pub fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
        .identity_of(&remote)
        .context("project could not be found in local storage")?;
    let payload = doc.project()?;
-
    let path = PathBuf::from(payload.name().clone());
+
    let path = PathBuf::from(payload.name());

    transport::local::register(storage.clone());

modified radicle-cli/src/commands/edit.rs
@@ -71,8 +71,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let repo = storage.repository(id)?;

    let payload = serde_json::to_string_pretty(&project.payload)?;
-
    match term::Editor::new().edit(&payload)? {
-
        Some(updated_payload) => {
+
    match term::Editor::new().extension("json").edit(payload) {
+
        Ok(Some(updated_payload)) => {
            project.payload = serde_json::from_str(&updated_payload)?;
            project.sign(&signer).and_then(|(_, sig)| {
                project.update(
@@ -83,7 +83,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                )
            })?;
        }
-
        None => return Err(anyhow!("Operation aborted!")),
+
        _ => return Err(anyhow!("Operation aborted!")),
    }

    term::success!("Update successful!");
modified radicle-cli/src/commands/id.rs
@@ -41,7 +41,7 @@ pub struct Metadata {
impl Metadata {
    fn edit(self) -> anyhow::Result<Self> {
        let yaml = serde_yaml::to_string(&self)?;
-
        match term::Editor::new().edit(&yaml)? {
+
        match term::Editor::new().edit(yaml)? {
            Some(meta) => Ok(serde_yaml::from_str(&meta).context("failed to parse proposal meta")?),
            None => return Err(anyhow!("Operation aborted!")),
        }
@@ -261,7 +261,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            let yes = confirm(interactive, "Are you sure you want to reject?");
            if yes {
                proposal.reject(rid, &signer)?;
-
                term::success!("Rejected proposal ✗");
+
                term::success!("Rejected proposal 👎");
                print(&proposal, &previous, None)?;
            }
        }
@@ -578,9 +578,9 @@ fn print_revision(revision: &identity::Revision, previous: &Identity<Oid>) -> an
    print!(
        "{}",
        if revision.is_quorum_reached(previous) {
-
            term::format::positive("✓ yes")
+
            term::format::positive("👍 yes")
        } else {
-
            term::format::negative("✗ no")
+
            term::format::negative("👎 no")
        }
    );
    term::blank();
modified radicle-cli/src/commands/init.rs
@@ -162,9 +162,9 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
    term::headline(&format!(
        "Initializing local 🌱 project in {}",
        if path == cwd {
-
            term::format::highlight(".")
+
            term::format::highlight(".").to_string()
        } else {
-
            term::format::highlight(path.display())
+
            term::format::highlight(path.display()).to_string()
        }
    ));

@@ -184,14 +184,14 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>

    let name = options.name.unwrap_or_else(|| {
        let default = path.file_name().map(|f| f.to_string_lossy().to_string());
-
        term::text_input("Name", default).unwrap()
+
        term::input("Name", default).unwrap()
    });
    let description = options
        .description
-
        .unwrap_or_else(|| term::text_input("Description", None).unwrap());
+
        .unwrap_or_else(|| term::input("Description", None).unwrap());
    let branch = options.branch.unwrap_or_else(|| {
        if interactive.yes() {
-
            term::text_input("Default branch", Some(head)).unwrap()
+
            term::input("Default branch", Some(head)).unwrap()
        } else {
            head
        }
modified radicle-cli/src/commands/issue.rs
@@ -218,8 +218,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        }
        Operation::React { id, reaction } => {
            if let Ok(mut issue) = issues.get_mut(&id) {
-
                let comment_id = term::comment_select(&issue).unwrap();
-
                issue.react(comment_id, reaction, &signer)?;
+
                let (comment_id, _) = term::comment_select(&issue).unwrap();
+
                issue.react(*comment_id, reaction, &signer)?;
            }
        }
        Operation::Open { title, description } => {
@@ -235,7 +235,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                description.unwrap_or("Enter a description...".to_owned())
            );

-
            if let Some(text) = term::Editor::new().edit(&doc)? {
+
            if let Ok(Some(text)) = term::Editor::new().edit(&doc) {
                let mut meta = String::new();
                let mut frontmatter = false;
                let mut lines = text.lines();
modified radicle-cli/src/commands/ls.rs
@@ -53,8 +53,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        let head = term::format::oid(head);
        table.push([
            term::format::bold(proj.name()),
-
            term::format::tertiary(id.urn()),
-
            term::format::secondary(head),
+
            term::format::tertiary(id.urn().as_str()),
+
            term::format::secondary(head.as_str()),
            term::format::italic(proj.description()),
        ]);
    });
modified radicle-cli/src/commands/merge.rs
@@ -222,8 +222,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            .dim()
            .italic()
            .to_string(),
-
        MergeStyle::Commit => term::format::style(merge_style.to_string())
-
            .yellow()
+
        MergeStyle::Commit => term::format::yellow(merge_style.to_string())
            .italic()
            .to_string(),
    };
@@ -312,19 +311,13 @@ fn merge_commit(
    writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?;

    // Offer user the chance to edit the message before committing.
-
    let merge_msg = match term::Editor::new()
-
        .require_save(true)
-
        .trim_newlines(true)
-
        .extension(".git-commit")
-
        .edit(&merge_msg)
-
        .unwrap()
-
    {
-
        Some(s) => s
+
    let merge_msg = match term::Editor::new().extension("git-commit").edit(merge_msg) {
+
        Ok(Some(s)) => s
            .lines()
            .filter(|l| !l.starts_with('#'))
            .collect::<Vec<_>>()
            .join("\n"),
-
        None => anyhow::bail!("user aborted merge"),
+
        _ => anyhow::bail!("user aborted merge"),
    };

    // Empty message aborts merge.
modified radicle-cli/src/commands/patch/common.rs
@@ -127,7 +127,7 @@ pub fn pretty_sync_status(
) -> anyhow::Result<String> {
    let (a, b) = repo.graph_ahead_behind(revision_oid, head_oid)?;
    if a == 0 && b == 0 {
-
        return Ok(term::format::dim("up to date"));
+
        return Ok(term::format::dim("up to date").to_string());
    }

    let ahead = term::format::positive(a);
@@ -143,7 +143,7 @@ pub fn pretty_commit_version(
    revision_oid: &Oid,
    repo: &Option<git::raw::Repository>,
) -> anyhow::Result<String> {
-
    let mut oid = term::format::secondary(term::format::oid(*revision_oid));
+
    let mut oid = term::format::secondary(term::format::oid(*revision_oid)).to_string();
    let mut branches: Vec<String> = vec![];

    if let Some(repo) = repo {
@@ -216,6 +216,8 @@ pub fn push_to_storage(
        if options.verbose {
            spinner.finish();
            term::blob(output);
+

+
            return Ok(());
        }
    }
    spinner.finish();
modified radicle-cli/src/commands/patch/list.rs
@@ -5,6 +5,7 @@ use radicle::git;
use radicle::prelude::*;
use radicle::profile::Profile;
use radicle::storage::git::Repository;
+
use unicode_width::UnicodeWidthStr;

use crate::terminal as term;

@@ -39,10 +40,7 @@ pub fn run(
        }
    }
    term::blank();
-
    term::print(format!(
-
        "-{}-",
-
        term::format::badge_secondary("YOU PROPOSED")
-
    ));
+
    term::print(term::format::badge_secondary("YOU PROPOSED"));

    if own.is_empty() {
        term::blank();
@@ -55,10 +53,7 @@ pub fn run(
        }
    }
    term::blank();
-
    term::print(format!(
-
        "-{}-",
-
        term::format::badge_secondary("OTHERS PROPOSED")
-
    ));
+
    term::print(term::format::badge_secondary("OTHERS PROPOSED"));

    if other.is_empty() {
        term::blank();
@@ -94,11 +89,9 @@ fn print(
    )];

    if you {
-
        author_info.push(term::format::secondary("(you)"));
+
        author_info.push(term::format::secondary("(you)").to_string());
    }
-
    author_info.push(term::format::dim(term::format::timestamp(
-
        &patch.timestamp(),
-
    )));
+
    author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp())).to_string());

    let (_, revision) = patch
        .latest()
@@ -120,17 +113,17 @@ fn print(
        let mut badges = Vec::new();

        if peer.delegate {
-
            badges.push(term::format::secondary("(delegate)"));
+
            badges.push(term::format::secondary("(delegate)").to_string());
        }
        if peer.id == *whoami {
-
            badges.push(term::format::secondary("(you)"));
+
            badges.push(term::format::secondary("(you)").to_string());
        }

        timeline.push((
            merge.timestamp,
            format!(
                "{}{} by {} {}",
-
                " ".repeat(term::text_width(prefix)),
+
                " ".repeat(prefix.width()),
                term::format::secondary(term::format::dim("✓ merged")),
                term::format::tertiary(peer.id),
                badges.join(" "),
@@ -147,17 +140,17 @@ fn print(
        let mut badges = Vec::new();

        if peer.delegate {
-
            badges.push(term::format::secondary("(delegate)"));
+
            badges.push(term::format::secondary("(delegate)").to_string());
        }
        if peer.id == *whoami {
-
            badges.push(term::format::secondary("(you)"));
+
            badges.push(term::format::secondary("(you)").to_string());
        }

        timeline.push((
            review.timestamp(),
            format!(
                "{}{} by {} {}",
-
                " ".repeat(term::text_width(prefix)),
+
                " ".repeat(prefix.width()),
                verdict,
                term::format::tertiary(reviewer),
                badges.join(" "),
modified radicle-cli/src/commands/self.rs
@@ -78,33 +78,45 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
    let mut table = term::Table::default();

    let did = profile.did();
-
    table.push(["ID", &term::format::tertiary(did)]);
+
    table.push([
+
        term::format::style("ID").to_string(),
+
        term::format::tertiary(did).to_string(),
+
    ]);

    let node_id = profile.id();
-
    table.push(["Node ID", &term::format::tertiary(node_id)]);
+
    table.push([
+
        term::format::style("Node ID").to_string(),
+
        term::format::tertiary(node_id).to_string(),
+
    ]);

    let ssh_short = ssh::fmt::fingerprint(node_id);
-
    table.push(["Key (hash)", &term::format::tertiary(ssh_short)]);
+
    table.push([
+
        term::format::style("Key (hash)").to_string(),
+
        term::format::tertiary(ssh_short).to_string(),
+
    ]);

    let ssh_long = ssh::fmt::key(node_id);
-
    table.push(["Key (full)", &term::format::tertiary(ssh_long)]);
+
    table.push([
+
        term::format::style("Key (full)").to_string(),
+
        term::format::tertiary(ssh_long).to_string(),
+
    ]);

    let storage_path = profile.home.storage();
    table.push([
-
        "Storage (git)",
-
        &term::format::tertiary(storage_path.display()),
+
        term::format::style("Storage (git)").to_string(),
+
        term::format::tertiary(storage_path.display()).to_string(),
    ]);

    let keys_path = profile.home.keys();
    table.push([
-
        "Storage (keys)",
-
        &term::format::tertiary(keys_path.display()),
+
        term::format::style("Storage (keys)").to_string(),
+
        term::format::tertiary(keys_path.display()).to_string(),
    ]);

    let node_path = profile.home.node();
    table.push([
-
        "Node (socket)",
-
        &term::format::tertiary(node_path.join("radicle.sock").display()),
+
        term::format::style("Node (socket)").to_string(),
+
        term::format::tertiary(node_path.join("radicle.sock").display()).to_string(),
    ]);

    table.render();
modified radicle-cli/src/terminal.rs
@@ -1,6 +1,9 @@
+
pub mod ansi;
pub mod args;
+
pub mod cell;
pub mod cob;
pub mod command;
+
pub mod editor;
pub mod format;
pub mod io;
pub mod patch;
@@ -11,12 +14,12 @@ pub mod textbox;
use std::ffi::OsString;
use std::process;

-
use dialoguer::console::style;
use radicle::profile::Profile;

+
pub use ansi::{paint, Paint};
pub use args::{Args, Error, Help};
-
pub use console::measure_text_width as text_width;
-
pub use dialoguer::Editor;
+
pub use editor::Editor;
+
pub use inquire::ui::Styled;
pub use io::*;
pub use spinner::{spinner, Spinner};
pub use table::Table;
@@ -97,14 +100,14 @@ where
            };
            eprintln!(
                "{} {} {} {}",
-
                style("==").red(),
-
                style("Error:").red(),
-
                style(format!("rad-{}:", help.name)).red(),
-
                style(&err).red()
+
                Paint::red("=="),
+
                Paint::red("Error:"),
+
                Paint::red(format!("rad-{}:", help.name)),
+
                Paint::red(err.to_string()),
            );

            if let Some(Error::WithHint { hint, .. }) = err.downcast_ref::<Error>() {
-
                eprintln!("{}", style(hint).yellow());
+
                eprintln!("{}", Paint::yellow(hint));
            }

            process::exit(1);
@@ -173,3 +176,7 @@ impl From<bool> for Interactive {
        }
    }
}
+

+
pub fn style<T>(item: T) -> Paint<T> {
+
    paint(item)
+
}
added radicle-cli/src/terminal/ansi.rs
@@ -0,0 +1,16 @@
+
//! A dead simple ANSI terminal color painting library.
+
//!
+
//! This library is a port of the `yansi` crate.
+
//! Copyright (c) 2017 Sergio Benitez
+
//!
+
mod color;
+
mod paint;
+
mod style;
+
#[cfg(test)]
+
mod tests;
+
mod windows;
+

+
pub use color::Color;
+
pub use paint::paint;
+
pub use paint::Paint;
+
pub use style::Style;
added radicle-cli/src/terminal/ansi/color.rs
@@ -0,0 +1,63 @@
+
use std::fmt;
+

+
use super::{Paint, Style};
+

+
/// An enum representing an ANSI color code.
+
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+
pub enum Color {
+
    /// No color has been set. Nothing is changed when applied.
+
    #[default]
+
    Unset,
+
    /// Black #0 (foreground code `30`, background code `40`).
+
    Black,
+
    /// Red: #1 (foreground code `31`, background code `41`).
+
    Red,
+
    /// Green: #2 (foreground code `32`, background code `42`).
+
    Green,
+
    /// Yellow: #3 (foreground code `33`, background code `43`).
+
    Yellow,
+
    /// Blue: #4 (foreground code `34`, background code `44`).
+
    Blue,
+
    /// Magenta: #5 (foreground code `35`, background code `45`).
+
    Magenta,
+
    /// Cyan: #6 (foreground code `36`, background code `46`).
+
    Cyan,
+
    /// White: #7 (foreground code `37`, background code `47`).
+
    White,
+
    /// A color number from 0 to 255, for use in 256-color terminals.
+
    Fixed(u8),
+
    /// A 24-bit RGB color, as specified by ISO-8613-3.
+
    RGB(u8, u8, u8),
+
}
+

+
impl Color {
+
    /// Constructs a new `Paint` structure that encapsulates `item` with the
+
    /// foreground color set to the color `self`.
+
    #[inline]
+
    pub fn paint<T>(self, item: T) -> Paint<T> {
+
        Paint::new(item).fg(self)
+
    }
+

+
    /// Constructs a new `Style` structure with the foreground color set to the
+
    /// color `self`.
+
    #[inline]
+
    pub const fn style(self) -> Style {
+
        Style::new(self)
+
    }
+

+
    pub(crate) fn ansi_fmt(&self, f: &mut dyn fmt::Write) -> fmt::Result {
+
        match *self {
+
            Color::Unset => Ok(()),
+
            Color::Black => write!(f, "0"),
+
            Color::Red => write!(f, "1"),
+
            Color::Green => write!(f, "2"),
+
            Color::Yellow => write!(f, "3"),
+
            Color::Blue => write!(f, "4"),
+
            Color::Magenta => write!(f, "5"),
+
            Color::Cyan => write!(f, "6"),
+
            Color::White => write!(f, "7"),
+
            Color::Fixed(num) => write!(f, "8;5;{num}"),
+
            Color::RGB(r, g, b) => write!(f, "8;2;{r};{g};{b}"),
+
        }
+
    }
+
}
added radicle-cli/src/terminal/ansi/paint.rs
@@ -0,0 +1,252 @@
+
use std::fmt;
+

+
use unicode_width::UnicodeWidthStr;
+

+
use super::color::Color;
+
use super::style::{Property, Style};
+

+
/// A structure encapsulating an item and styling.
+
#[derive(Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+
pub struct Paint<T> {
+
    pub item: T,
+
    pub style: Style,
+
}
+

+
impl Paint<&str> {
+
    /// Return plain content.
+
    pub fn content(&self) -> &str {
+
        self.item
+
    }
+
}
+

+
impl Paint<String> {
+
    /// Return plain content.
+
    pub fn content(&self) -> &str {
+
        self.item.as_str()
+
    }
+
}
+

+
impl<T> From<T> for Paint<T> {
+
    fn from(value: T) -> Self {
+
        Self::new(value)
+
    }
+
}
+

+
impl From<&str> for Paint<String> {
+
    fn from(item: &str) -> Self {
+
        Self::new(item.to_string())
+
    }
+
}
+

+
impl<T> Paint<T> {
+
    /// Constructs a new `Paint` structure encapsulating `item` with no set
+
    /// styling.
+
    #[inline]
+
    pub const fn new(item: T) -> Paint<T> {
+
        Paint {
+
            item,
+
            style: Style {
+
                foreground: Color::Unset,
+
                background: Color::Unset,
+
                properties: Property::new(),
+
                wrap: false,
+
            },
+
        }
+
    }
+

+
    /// Constructs a new _wrapping_ `Paint` structure encapsulating `item` with
+
    /// default styling.
+
    ///
+
    /// A wrapping `Paint` converts all color resets written out by the internal
+
    /// value to the styling of itself. This allows for seamless color wrapping
+
    /// of other colored text.
+
    ///
+
    /// # Performance
+
    ///
+
    /// In order to wrap an internal value, the internal value must first be
+
    /// written out to a local buffer and examined. As a result, displaying a
+
    /// wrapped value is likely to result in a heap allocation and copy.
+
    #[inline]
+
    pub const fn wrapping(item: T) -> Paint<T> {
+
        Paint::new(item).wrap()
+
    }
+

+
    /// Constructs a new `Paint` structure encapsulating `item` with the
+
    /// foreground color set to the RGB color `r`, `g`, `b`.
+
    #[inline]
+
    pub const fn rgb(r: u8, g: u8, b: u8, item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::RGB(r, g, b))
+
    }
+

+
    /// Constructs a new `Paint` structure encapsulating `item` with the
+
    /// foreground color set to the fixed 8-bit color `color`.
+
    #[inline]
+
    pub const fn fixed(color: u8, item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Fixed(color))
+
    }
+

+
    pub const fn red(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Red)
+
    }
+

+
    pub const fn black(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Black)
+
    }
+

+
    pub const fn yellow(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Yellow)
+
    }
+

+
    pub const fn green(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Green)
+
    }
+

+
    pub const fn cyan(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Cyan)
+
    }
+

+
    pub const fn blue(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Blue)
+
    }
+

+
    pub const fn magenta(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::Magenta)
+
    }
+

+
    pub const fn white(item: T) -> Paint<T> {
+
        Paint::new(item).fg(Color::White)
+
    }
+

+
    /// Retrieves the style currently set on `self`.
+
    #[inline]
+
    pub const fn style(&self) -> Style {
+
        self.style
+
    }
+

+
    /// Retrieves a borrow to the inner item.
+
    #[inline]
+
    pub const fn inner(&self) -> &T {
+
        &self.item
+
    }
+

+
    /// Sets the style of `self` to `style`.
+
    #[inline]
+
    pub fn with_style(mut self, style: Style) -> Paint<T> {
+
        self.style = style;
+
        self
+
    }
+

+
    /// Makes `self` a _wrapping_ `Paint`.
+
    ///
+
    /// A wrapping `Paint` converts all color resets written out by the internal
+
    /// value to the styling of itself. This allows for seamless color wrapping
+
    /// of other colored text.
+
    ///
+
    /// # Performance
+
    ///
+
    /// In order to wrap an internal value, the internal value must first be
+
    /// written out to a local buffer and examined. As a result, displaying a
+
    /// wrapped value is likely to result in a heap allocation and copy.
+
    #[inline]
+
    pub const fn wrap(mut self) -> Paint<T> {
+
        self.style.wrap = true;
+
        self
+
    }
+

+
    /// Sets the foreground to `color`.
+
    #[inline]
+
    pub const fn fg(mut self, color: Color) -> Paint<T> {
+
        self.style.foreground = color;
+
        self
+
    }
+

+
    /// Sets the background to `color`.
+
    #[inline]
+
    pub const fn bg(mut self, color: Color) -> Paint<T> {
+
        self.style.background = color;
+
        self
+
    }
+

+
    pub fn bold(mut self) -> Self {
+
        self.style.properties.set(Property::BOLD);
+
        self
+
    }
+

+
    pub fn dim(mut self) -> Self {
+
        self.style.properties.set(Property::DIM);
+
        self
+
    }
+

+
    pub fn italic(mut self) -> Self {
+
        self.style.properties.set(Property::ITALIC);
+
        self
+
    }
+

+
    pub fn underline(mut self) -> Self {
+
        self.style.properties.set(Property::UNDERLINE);
+
        self
+
    }
+

+
    pub fn invert(mut self) -> Self {
+
        self.style.properties.set(Property::INVERT);
+
        self
+
    }
+

+
    pub fn strikethrough(mut self) -> Self {
+
        self.style.properties.set(Property::STRIKETHROUGH);
+
        self
+
    }
+

+
    pub fn blink(mut self) -> Self {
+
        self.style.properties.set(Property::BLINK);
+
        self
+
    }
+

+
    pub fn hidden(mut self) -> Self {
+
        self.style.properties.set(Property::HIDDEN);
+
        self
+
    }
+
}
+

+
impl<T: UnicodeWidthStr> UnicodeWidthStr for Paint<T> {
+
    fn width(&self) -> usize {
+
        self.item.width()
+
    }
+

+
    fn width_cjk(&self) -> usize {
+
        self.item.width_cjk()
+
    }
+
}
+

+
impl<T: fmt::Display> fmt::Display for Paint<T> {
+
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+
        if Paint::is_enabled() && self.style.wrap {
+
            let mut prefix = String::new();
+
            prefix.push_str("\x1B[0m");
+
            self.style.fmt_prefix(&mut prefix)?;
+
            self.style.fmt_prefix(f)?;
+

+
            let item = format!("{}", self.item).replace("\x1B[0m", &prefix);
+
            fmt::Display::fmt(&item, f)?;
+
            self.style.fmt_suffix(f)
+
        } else if Paint::is_enabled() {
+
            self.style.fmt_prefix(f)?;
+
            fmt::Display::fmt(&self.item, f)?;
+
            self.style.fmt_suffix(f)
+
        } else {
+
            fmt::Display::fmt(&self.item, f)
+
        }
+
    }
+
}
+

+
impl Paint<()> {
+
    /// Returns `true` if coloring is enabled and `false` otherwise.
+
    pub fn is_enabled() -> bool {
+
        concolor::get(concolor::Stream::Stdout).ansi_color()
+
    }
+
}
+

+
/// Shorthand for [`Paint::new`].
+
pub fn paint<T>(item: T) -> Paint<T> {
+
    Paint::new(item)
+
}
added radicle-cli/src/terminal/ansi/style.rs
@@ -0,0 +1,267 @@
+
use std::fmt::{self, Display};
+
use std::hash::{Hash, Hasher};
+
use std::ops::BitOr;
+

+
use super::{Color, Paint};
+

+
#[derive(Default, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+
pub struct Property(u8);
+

+
impl Property {
+
    pub const BOLD: Self = Property(1 << 0);
+
    pub const DIM: Self = Property(1 << 1);
+
    pub const ITALIC: Self = Property(1 << 2);
+
    pub const UNDERLINE: Self = Property(1 << 3);
+
    pub const BLINK: Self = Property(1 << 4);
+
    pub const INVERT: Self = Property(1 << 5);
+
    pub const HIDDEN: Self = Property(1 << 6);
+
    pub const STRIKETHROUGH: Self = Property(1 << 7);
+

+
    pub const fn new() -> Self {
+
        Property(0)
+
    }
+

+
    #[inline(always)]
+
    pub const fn contains(self, other: Property) -> bool {
+
        (other.0 & self.0) == other.0
+
    }
+

+
    #[inline(always)]
+
    pub fn set(&mut self, other: Property) {
+
        self.0 |= other.0;
+
    }
+

+
    #[inline(always)]
+
    pub fn iter(self) -> Iter {
+
        Iter {
+
            index: 0,
+
            properties: self,
+
        }
+
    }
+
}
+

+
impl BitOr for Property {
+
    type Output = Self;
+

+
    #[inline(always)]
+
    fn bitor(self, rhs: Self) -> Self {
+
        Property(self.0 | rhs.0)
+
    }
+
}
+

+
pub struct Iter {
+
    index: u8,
+
    properties: Property,
+
}
+

+
impl Iterator for Iter {
+
    type Item = usize;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        while self.index < 8 {
+
            let index = self.index;
+
            self.index += 1;
+

+
            if self.properties.contains(Property(1 << index)) {
+
                return Some(index as usize);
+
            }
+
        }
+

+
        None
+
    }
+
}
+

+
/// Represents a set of styling options.
+
#[repr(packed)]
+
#[derive(Default, Debug, Eq, Ord, PartialOrd, Copy, Clone)]
+
pub struct Style {
+
    pub(crate) foreground: Color,
+
    pub(crate) background: Color,
+
    pub(crate) properties: Property,
+
    pub(crate) wrap: bool,
+
}
+

+
impl PartialEq for Style {
+
    fn eq(&self, other: &Style) -> bool {
+
        self.foreground == other.foreground
+
            && self.background == other.background
+
            && self.properties == other.properties
+
    }
+
}
+

+
impl Hash for Style {
+
    fn hash<H: Hasher>(&self, state: &mut H) {
+
        self.foreground.hash(state);
+
        self.background.hash(state);
+
        self.properties.hash(state);
+
    }
+
}
+

+
#[inline]
+
fn write_spliced<T: Display>(c: &mut bool, f: &mut dyn fmt::Write, t: T) -> fmt::Result {
+
    if *c {
+
        write!(f, ";{t}")
+
    } else {
+
        *c = true;
+
        write!(f, "{t}")
+
    }
+
}
+

+
impl Style {
+
    /// Default style with the foreground set to `color` and no other set
+
    /// properties.
+
    #[inline]
+
    pub const fn new(color: Color) -> Style {
+
        // Avoiding `Default::default` since unavailable as `const`
+
        Self {
+
            foreground: color,
+
            background: Color::Unset,
+
            properties: Property::new(),
+
            wrap: false,
+
        }
+
    }
+

+
    /// Sets the foreground to `color`.
+
    #[inline]
+
    pub const fn fg(mut self, color: Color) -> Style {
+
        self.foreground = color;
+
        self
+
    }
+

+
    /// Sets the background to `color`.
+
    #[inline]
+
    pub const fn bg(mut self, color: Color) -> Style {
+
        self.background = color;
+
        self
+
    }
+

+
    /// Sets `self` to be wrapping.
+
    ///
+
    /// A wrapping `Style` converts all color resets written out by the internal
+
    /// value to the styling of itself. This allows for seamless color wrapping
+
    /// of other colored text.
+
    ///
+
    /// # Performance
+
    ///
+
    /// In order to wrap an internal value, the internal value must first be
+
    /// written out to a local buffer and examined. As a result, displaying a
+
    /// wrapped value is likely to result in a heap allocation and copy.
+
    #[inline]
+
    pub const fn wrap(mut self) -> Style {
+
        self.wrap = true;
+
        self
+
    }
+

+
    pub fn bold(mut self) -> Self {
+
        self.properties.set(Property::BOLD);
+
        self
+
    }
+

+
    pub fn dim(mut self) -> Self {
+
        self.properties.set(Property::DIM);
+
        self
+
    }
+

+
    pub fn italic(mut self) -> Self {
+
        self.properties.set(Property::ITALIC);
+
        self
+
    }
+

+
    pub fn underline(mut self) -> Self {
+
        self.properties.set(Property::UNDERLINE);
+
        self
+
    }
+

+
    pub fn invert(mut self) -> Self {
+
        self.properties.set(Property::INVERT);
+
        self
+
    }
+

+
    pub fn strikethrough(mut self) -> Self {
+
        self.properties.set(Property::STRIKETHROUGH);
+
        self
+
    }
+

+
    /// Constructs a new `Paint` structure that encapsulates `item` with the
+
    /// style set to `self`.
+
    #[inline]
+
    pub fn paint<T>(self, item: T) -> Paint<T> {
+
        Paint::new(item).with_style(self)
+
    }
+

+
    /// Returns the foreground color of `self`.
+
    #[inline]
+
    pub const fn fg_color(&self) -> Color {
+
        self.foreground
+
    }
+

+
    /// Returns the foreground color of `self`.
+
    #[inline]
+
    pub const fn bg_color(&self) -> Color {
+
        self.background
+
    }
+

+
    /// Returns `true` if `self` is wrapping.
+
    #[inline]
+
    pub const fn is_wrapping(&self) -> bool {
+
        self.wrap
+
    }
+

+
    #[inline(always)]
+
    fn is_plain(&self) -> bool {
+
        self == &Style::default()
+
    }
+

+
    /// Writes the ANSI code prefix for the currently set styles.
+
    ///
+
    /// This method is intended to be used inside of [`fmt::Display`] and
+
    /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
+
    /// users should use [`Paint`] for all painting needs.
+
    ///
+
    /// This method writes the ANSI code prefix irrespective of whether painting
+
    /// is currently enabled or disabled. To write the prefix only if painting
+
    /// is enabled, condition a call to this method on [`Paint::is_enabled()`].
+
    pub fn fmt_prefix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
+
        // A user may just want a code-free string when no styles are applied.
+
        if self.is_plain() {
+
            return Ok(());
+
        }
+

+
        let mut splice = false;
+
        write!(f, "\x1B[")?;
+

+
        for i in self.properties.iter() {
+
            let k = if i >= 5 { i + 2 } else { i + 1 };
+
            write_spliced(&mut splice, f, k)?;
+
        }
+

+
        if self.background != Color::Unset {
+
            write_spliced(&mut splice, f, "4")?;
+
            self.background.ansi_fmt(f)?;
+
        }
+

+
        if self.foreground != Color::Unset {
+
            write_spliced(&mut splice, f, "3")?;
+
            self.foreground.ansi_fmt(f)?;
+
        }
+

+
        // All the codes end with an `m`.
+
        write!(f, "m")
+
    }
+

+
    /// Writes the ANSI code suffix for the currently set styles.
+
    ///
+
    /// This method is intended to be used inside of [`fmt::Display`] and
+
    /// [`fmt::Debug`] implementations for custom or specialized use-cases. Most
+
    /// users should use [`Paint`] for all painting needs.
+
    ///
+
    /// This method writes the ANSI code suffix irrespective of whether painting
+
    /// is currently enabled or disabled. To write the suffix only if painting
+
    /// is enabled, condition a call to this method on [`Paint::is_enabled()`].
+
    pub fn fmt_suffix(&self, f: &mut dyn fmt::Write) -> fmt::Result {
+
        if self.is_plain() {
+
            return Ok(());
+
        }
+
        write!(f, "\x1B[0m")
+
    }
+
}
added radicle-cli/src/terminal/ansi/tests.rs
@@ -0,0 +1,279 @@
+
use std::sync::Mutex;
+

+
use super::Color::*;
+
use super::Paint;
+

+
/// Ensures tests are running serially.
+
static SERIAL: Mutex<()> = Mutex::new(());
+

+
#[test]
+
fn colors_enabled() {
+
    let _guard = SERIAL.lock();
+

+
    concolor::set(concolor::ColorChoice::Always);
+

+
    assert_eq!(
+
        Paint::new("text/plain").to_string(),
+
        "text/plain".to_string()
+
    );
+
    assert_eq!(
+
        Paint::red("hi").to_string(),
+
        "\x1B[31mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::black("hi").to_string(),
+
        "\x1B[30mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::yellow("hi").bold().to_string(),
+
        "\x1B[1;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").fg(Yellow).bold().to_string(),
+
        "\x1B[1;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").underline().to_string(),
+
        "\x1B[4;34mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::green("hi").bold().underline().to_string(),
+
        "\x1B[1;4;32mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::green("hi").underline().bold().to_string(),
+
        "\x1B[1;4;32mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::magenta("hi").bg(White).to_string(),
+
        "\x1B[47;35mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
+
        "\x1B[44;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
+
        "\x1B[44;33mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().bg(White).to_string(),
+
        "\x1B[1;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bg(White).to_string(),
+
        "\x1B[4;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().underline().bg(White).to_string(),
+
        "\x1B[1;4;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bold().bg(White).to_string(),
+
        "\x1B[1;4;47;36mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").to_string(),
+
        "\x1B[38;5;100mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Magenta).to_string(),
+
        "\x1B[45;38;5;100mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
+
        "\x1B[48;5;200;38;5;100mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi").to_string(),
+
        "\x1B[38;2;70;130;180mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
+
        "\x1B[44;38;2;70;130;180mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
+
        "\x1B[48;2;70;130;180;34mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi")
+
            .bg(RGB(5, 10, 15))
+
            .to_string(),
+
        "\x1B[48;2;5;10;15;38;2;70;130;180mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").bold().to_string(),
+
        "\x1B[1mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").underline().to_string(),
+
        "\x1B[4mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").bold().underline().to_string(),
+
        "\x1B[1;4mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").dim().to_string(),
+
        "\x1B[2mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").italic().to_string(),
+
        "\x1B[3mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").blink().to_string(),
+
        "\x1B[5mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").invert().to_string(),
+
        "\x1B[7mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").hidden().to_string(),
+
        "\x1B[8mhi\x1B[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").strikethrough().to_string(),
+
        "\x1B[9mhi\x1B[0m".to_string()
+
    );
+
}
+

+
#[test]
+
fn colors_disabled() {
+
    let _guard = SERIAL.lock();
+

+
    concolor::set(concolor::ColorChoice::Never);
+

+
    assert_eq!(
+
        Paint::new("text/plain").to_string(),
+
        "text/plain".to_string()
+
    );
+
    assert_eq!(Paint::red("hi").to_string(), "hi".to_string());
+
    assert_eq!(Paint::black("hi").to_string(), "hi".to_string());
+
    assert_eq!(Paint::yellow("hi").bold().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::new("hi").fg(Yellow).bold().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::blue("hi").underline().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::green("hi").bold().underline().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::green("hi").underline().bold().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::magenta("hi").bg(White).to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::red("hi").bg(Blue).fg(Yellow).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bg(Blue).fg(Yellow).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").bold().underline().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::cyan("hi").underline().bold().bg(White).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::fixed(100, "hi").to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Magenta).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::fixed(100, "hi").bg(Fixed(200)).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::rgb(70, 130, 180, "hi").to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi").bg(Blue).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").bg(RGB(70, 130, 180)).to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::blue("hi").bg(RGB(70, 130, 180)).wrap().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::rgb(70, 130, 180, "hi")
+
            .bg(RGB(5, 10, 15))
+
            .to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::new("hi").bold().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").underline().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::new("hi").bold().underline().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(Paint::new("hi").dim().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").italic().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").blink().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").invert().to_string(), "hi".to_string());
+
    assert_eq!(Paint::new("hi").hidden().to_string(), "hi".to_string());
+
    assert_eq!(
+
        Paint::new("hi").strikethrough().to_string(),
+
        "hi".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new("hi").strikethrough().wrap().to_string(),
+
        "hi".to_string()
+
    );
+
}
+

+
#[test]
+
fn wrapping() {
+
    let _guard = SERIAL.lock();
+
    let inner = || format!("{} b {}", Paint::red("a"), Paint::green("c"));
+
    let inner2 = || format!("0 {} 1", Paint::magenta(&inner()).wrap());
+

+
    concolor::set(concolor::ColorChoice::Always);
+

+
    assert_eq!(
+
        Paint::new("text/plain").wrap().to_string(),
+
        "text/plain".to_string()
+
    );
+
    assert_eq!(Paint::new(&inner()).wrap().to_string(), inner());
+
    assert_eq!(
+
        Paint::new(&inner()).wrap().to_string(),
+
        "\u{1b}[31ma\u{1b}[0m b \u{1b}[32mc\u{1b}[0m".to_string()
+
    );
+
    assert_eq!(
+
        Paint::new(&inner()).fg(Blue).wrap().to_string(),
+
        "\u{1b}[34m\u{1b}[31ma\u{1b}[0m\u{1b}[34m b \
+
            \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[0m"
+
            .to_string()
+
    );
+
    assert_eq!(Paint::new(&inner2()).wrap().to_string(), inner2());
+
    assert_eq!(
+
        Paint::new(&inner2()).wrap().to_string(),
+
        "0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[35m b \
+
            \u{1b}[32mc\u{1b}[0m\u{1b}[35m\u{1b}[0m 1"
+
            .to_string()
+
    );
+
    assert_eq!(
+
        Paint::new(&inner2()).fg(Blue).wrap().to_string(),
+
        "\u{1b}[34m0 \u{1b}[35m\u{1b}[31ma\u{1b}[0m\u{1b}[34m\u{1b}[35m b \
+
            \u{1b}[32mc\u{1b}[0m\u{1b}[34m\u{1b}[35m\u{1b}[0m\u{1b}[34m 1\u{1b}[0m"
+
            .to_string()
+
    );
+
}
added radicle-cli/src/terminal/ansi/windows.rs
@@ -0,0 +1,64 @@
+
#[cfg(windows)]
+
mod windows_console {
+
    use std::os::raw::c_void;
+

+
    #[allow(non_camel_case_types)]
+
    type c_ulong = u32;
+
    #[allow(non_camel_case_types)]
+
    type c_int = i32;
+
    type DWORD = c_ulong;
+
    type LPDWORD = *mut DWORD;
+
    type HANDLE = *mut c_void;
+
    type BOOL = c_int;
+

+
    const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = 0x0004;
+
    const STD_OUTPUT_HANDLE: DWORD = 0xFFFFFFF5;
+
    const STD_ERROR_HANDLE: DWORD = 0xFFFFFFF4;
+
    const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
+
    const FALSE: BOOL = 0;
+
    const TRUE: BOOL = 1;
+

+
    // This is the win32 console API, taken from the 'winapi' crate.
+
    extern "system" {
+
        fn GetStdHandle(nStdHandle: DWORD) -> HANDLE;
+
        fn GetConsoleMode(hConsoleHandle: HANDLE, lpMode: LPDWORD) -> BOOL;
+
        fn SetConsoleMode(hConsoleHandle: HANDLE, dwMode: DWORD) -> BOOL;
+
    }
+

+
    unsafe fn get_handle(handle_num: DWORD) -> Result<HANDLE, ()> {
+
        match GetStdHandle(handle_num) {
+
            handle if handle == INVALID_HANDLE_VALUE => Err(()),
+
            handle => Ok(handle),
+
        }
+
    }
+

+
    unsafe fn enable_vt(handle: HANDLE) -> Result<(), ()> {
+
        let mut dw_mode: DWORD = 0;
+
        if GetConsoleMode(handle, &mut dw_mode) == FALSE {
+
            return Err(());
+
        }
+

+
        dw_mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
+
        match SetConsoleMode(handle, dw_mode) {
+
            result if result == TRUE => Ok(()),
+
            _ => Err(()),
+
        }
+
    }
+

+
    unsafe fn enable_ansi_colors_raw() -> Result<bool, ()> {
+
        let stdout_handle = get_handle(STD_OUTPUT_HANDLE)?;
+
        let stderr_handle = get_handle(STD_ERROR_HANDLE)?;
+

+
        enable_vt(stdout_handle)?;
+
        if stdout_handle != stderr_handle {
+
            enable_vt(stderr_handle)?;
+
        }
+

+
        Ok(true)
+
    }
+

+
    #[inline]
+
    pub fn enable_ansi_colors() -> bool {
+
        unsafe { enable_ansi_colors_raw().unwrap_or(false) }
+
    }
+
}
added radicle-cli/src/terminal/cell.rs
@@ -0,0 +1,146 @@
+
use std::fmt::Display;
+

+
use super::Paint;
+

+
use unicode_width::UnicodeWidthStr;
+

+
/// Text that can be displayed on the terminal, measured, truncated and padded.
+
pub trait Cell: Display {
+
    /// Type after truncation.
+
    type Truncated: Cell;
+
    /// Type after padding.
+
    type Padded: Cell;
+

+
    /// Cell display width in number of terminal columns.
+
    fn width(&self) -> usize;
+
    /// Truncate cell if longer than given width. Shows the delimiter if truncated.
+
    fn truncate(&self, width: usize, delim: &str) -> Self::Truncated;
+
    /// Pad the cell so that it is the given width, while keeping the content left-aligned.
+
    fn pad(&self, width: usize) -> Self::Padded;
+
}
+

+
impl Cell for Paint<String> {
+
    type Truncated = Self;
+
    type Padded = Self;
+

+
    fn width(&self) -> usize {
+
        UnicodeWidthStr::width(self.content())
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Self {
+
        Self {
+
            item: self.item.truncate(width, delim),
+
            style: self.style,
+
        }
+
    }
+

+
    fn pad(&self, width: usize) -> Self {
+
        Self {
+
            item: self.item.pad(width),
+
            style: self.style,
+
        }
+
    }
+
}
+

+
impl Cell for Paint<&str> {
+
    type Truncated = Paint<String>;
+
    type Padded = Paint<String>;
+

+
    fn width(&self) -> usize {
+
        Cell::width(self.item)
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Paint<String> {
+
        Paint {
+
            item: self.item.truncate(width, delim),
+
            style: self.style,
+
        }
+
    }
+

+
    fn pad(&self, width: usize) -> Paint<String> {
+
        Paint {
+
            item: self.item.pad(width),
+
            style: self.style,
+
        }
+
    }
+
}
+

+
impl Cell for String {
+
    type Truncated = Self;
+
    type Padded = Self;
+

+
    fn width(&self) -> usize {
+
        Cell::width(self.as_str())
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Self {
+
        self.as_str().truncate(width, delim)
+
    }
+

+
    fn pad(&self, width: usize) -> Self {
+
        self.as_str().pad(width)
+
    }
+
}
+

+
impl Cell for str {
+
    type Truncated = String;
+
    type Padded = String;
+

+
    fn width(&self) -> usize {
+
        UnicodeWidthStr::width(self)
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> String {
+
        use unicode_segmentation::UnicodeSegmentation as _;
+

+
        if width < Cell::width(self) {
+
            let d = Cell::width(delim);
+
            if width < d {
+
                // If we can't even fit the delimiter, just return an empty string.
+
                return String::new();
+
            }
+
            // Find the unicode byte boundary where the display width is the largest,
+
            // while being smaller than the given max width.
+
            let mut cols = 0; // Number of visual columns we need.
+
            let mut boundary = 0; // Boundary in bytes.
+
            for g in self.graphemes(true) {
+
                let c = Cell::width(g);
+
                if cols + c + d > width {
+
                    break;
+
                }
+
                boundary += g.len();
+
                cols += c;
+
            }
+
            format!("{}{delim}", &self[..boundary])
+
        } else {
+
            self.to_owned()
+
        }
+
    }
+

+
    fn pad(&self, max: usize) -> String {
+
        let width = Cell::width(self);
+

+
        if width < max {
+
            format!("{self}{}", " ".repeat(max - width))
+
        } else {
+
            self.to_owned()
+
        }
+
    }
+
}
+

+
impl<T: Cell + ?Sized> Cell for &T {
+
    type Truncated = T::Truncated;
+
    type Padded = T::Padded;
+

+
    fn width(&self) -> usize {
+
        T::width(self)
+
    }
+

+
    fn truncate(&self, width: usize, delim: &str) -> Self::Truncated {
+
        T::truncate(self, width, delim)
+
    }
+

+
    fn pad(&self, width: usize) -> Self::Padded {
+
        T::pad(self, width)
+
    }
+
}
added radicle-cli/src/terminal/editor.rs
@@ -0,0 +1,94 @@
+
use std::ffi::OsString;
+
use std::io::Write;
+
use std::path::PathBuf;
+
use std::process;
+
use std::{env, fs, io};
+

+
pub const COMMENT_FILE: &str = "RAD_COMMENT";
+

+
/// Allows for text input in the configured editor.
+
pub struct Editor {
+
    path: PathBuf,
+
}
+

+
impl Drop for Editor {
+
    fn drop(&mut self) {
+
        fs::remove_file(&self.path).ok();
+
    }
+
}
+

+
impl Default for Editor {
+
    fn default() -> Self {
+
        Self::new()
+
    }
+
}
+

+
impl Editor {
+
    /// Create a new editor.
+
    pub fn new() -> Self {
+
        let path = env::temp_dir().join(COMMENT_FILE);
+

+
        Self { path }
+
    }
+

+
    /// Set the file extension.
+
    pub fn extension(mut self, ext: &str) -> Self {
+
        let ext = ext.trim_start_matches('.');
+

+
        self.path.set_extension(ext);
+
        self
+
    }
+

+
    /// Open the editor and return the edited text.
+
    ///
+
    /// If the text hasn't changed from the initial contents of the editor,
+
    /// return `None`.
+
    pub fn edit(&mut self, initial: impl ToString) -> io::Result<Option<String>> {
+
        let initial = initial.to_string();
+
        let mut file = fs::OpenOptions::new()
+
            .write(true)
+
            .create(true)
+
            .open(&self.path)?;
+

+
        if file.metadata()?.len() == 0 {
+
            file.write_all(initial.as_bytes())?;
+
            if !initial.ends_with('\n') {
+
                file.write_all(b"\n")?;
+
            }
+
            file.flush()?;
+
        }
+

+
        let Some(cmd) = self::default_editor() else {
+
            return Err(
+
                io::Error::new(
+
                    io::ErrorKind::NotFound,
+
                    "editor not configured: the `EDITOR` environment variable is not set"
+
                )
+
            );
+
        };
+
        process::Command::new(cmd).arg(&self.path).spawn()?.wait()?;
+

+
        let text = fs::read_to_string(&self.path)?;
+
        let text = text.strip_prefix(&initial).unwrap_or(&text);
+

+
        if text.trim().is_empty() {
+
            return Ok(None);
+
        }
+
        Ok(Some(text.to_owned()))
+
    }
+
}
+

+
/// Get the default editor command.
+
pub fn default_editor() -> Option<OsString> {
+
    if let Ok(visual) = env::var("VISUAL") {
+
        if !visual.is_empty() {
+
            return Some(visual.into());
+
        }
+
    }
+
    if let Ok(editor) = env::var("EDITOR") {
+
        if !editor.is_empty() {
+
            return Some(editor.into());
+
        }
+
    }
+
    None
+
}
modified radicle-cli/src/terminal/format.rs
@@ -1,6 +1,6 @@
use std::{fmt, time};

-
pub use dialoguer::console::style;
+
pub use crate::terminal::{style, Paint};

use radicle::cob::{ObjectId, Timestamp};
use radicle::node::NodeId;
@@ -88,58 +88,74 @@ impl<'a> fmt::Display for Identity<'a> {
    }
}

-
pub fn negative<D: std::fmt::Display>(msg: D) -> String {
-
    style(msg).red().bright().to_string()
+
pub fn negative<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::red(msg).bold()
}

-
pub fn positive<D: std::fmt::Display>(msg: D) -> String {
-
    style(msg).green().bright().to_string()
+
pub fn positive<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::green(msg).bold()
}

-
pub fn secondary<D: std::fmt::Display>(msg: D) -> String {
-
    style(msg).blue().bright().to_string()
+
pub fn secondary<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::blue(msg).bold()
}

-
pub fn tertiary<D: std::fmt::Display>(msg: D) -> String {
-
    style(msg).cyan().to_string()
+
pub fn tertiary<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::cyan(msg)
}

-
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> String {
-
    style(msg).cyan().bold().to_string()
+
pub fn tertiary_bold<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::cyan(msg).bold()
}

-
pub fn yellow<D: std::fmt::Display>(msg: D) -> String {
-
    style(msg).yellow().to_string()
+
pub fn yellow<D: std::fmt::Display>(msg: D) -> Paint<D> {
+
    Paint::yellow(msg)
}

-
pub fn highlight<D: std::fmt::Display>(input: D) -> String {
-
    style(input).green().bright().to_string()
+
pub fn highlight<D: std::fmt::Debug + std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::green(input).bold()
}

-
pub fn badge_primary<D: std::fmt::Display>(input: D) -> String {
-
    style(format!(" {input} ")).magenta().reverse().to_string()
+
pub fn badge_primary<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::magenta(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
}

-
pub fn badge_positive<D: std::fmt::Display>(input: D) -> String {
-
    style(format!(" {input} ")).green().reverse().to_string()
+
pub fn badge_positive<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::green(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
}

-
pub fn badge_negative<D: std::fmt::Display>(input: D) -> String {
-
    style(format!(" {input} ")).red().reverse().to_string()
+
pub fn badge_negative<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::red(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
}

-
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> String {
-
    style(format!(" {input} ")).blue().reverse().to_string()
+
pub fn badge_secondary<D: std::fmt::Display>(input: D) -> Paint<String> {
+
    if Paint::is_enabled() {
+
        Paint::blue(format!(" {input} ")).invert()
+
    } else {
+
        Paint::new(format!("❲{input}❳"))
+
    }
}

-
pub fn bold<D: std::fmt::Display>(input: D) -> String {
-
    style(input).white().bright().bold().to_string()
+
pub fn bold<D: std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::white(input).bold()
}

-
pub fn dim<D: std::fmt::Display>(input: D) -> String {
-
    style(input).dim().to_string()
+
pub fn dim<D: std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::new(input).dim()
}

-
pub fn italic<D: std::fmt::Display>(input: D) -> String {
-
    style(input).italic().dim().to_string()
+
pub fn italic<D: std::fmt::Display>(input: D) -> Paint<D> {
+
    Paint::new(input).italic().dim()
}
modified radicle-cli/src/terminal/io.rs
@@ -1,24 +1,39 @@
use std::fmt;
-
use std::str::FromStr;

-
use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password};
+
use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled};
+
use inquire::InquireError;
+
use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password, Select};
+
use once_cell::sync::Lazy;

use radicle::cob::issue::Issue;
-
use radicle::cob::thread::CommentId;
-
use radicle::crypto::ssh::keystore::Passphrase;
+
use radicle::cob::thread::{Comment, CommentId};
+
use radicle::crypto::ssh::keystore::{MemorySigner, Passphrase};
use radicle::crypto::Signer;
use radicle::profile;
use radicle::profile::Profile;

-
use radicle_crypto::ssh::keystore::MemorySigner;
-

use super::command;
use super::format;
use super::spinner::spinner;
use super::Error;
+
use super::{style, Paint};

+
pub const ERROR_PREFIX: Paint<&str> = Paint::red("✗");
pub const TAB: &str = "    ";

+
/// Render configuration.
+
pub static CONFIG: Lazy<RenderConfig> = Lazy::new(|| RenderConfig {
+
    prompt: StyleSheet::new().with_fg(Color::LightCyan),
+
    prompt_prefix: Styled::new("?").with_fg(Color::LightBlue),
+
    answered_prompt_prefix: Styled::new("✓").with_fg(Color::LightGreen),
+
    answer: StyleSheet::new(),
+
    highlighted_option_prefix: Styled::new("*").with_fg(Color::LightYellow),
+
    help_message: StyleSheet::new().with_fg(Color::DarkGrey),
+
    error_message: ErrorMessageRenderConfig::default_colored()
+
        .with_prefix(Styled::new("✗").with_fg(Color::LightRed)),
+
    ..RenderConfig::default_colored()
+
});
+

#[macro_export]
macro_rules! info {
    ($($arg:tt)*) => ({
@@ -45,17 +60,15 @@ pub use success;
pub use tip;

pub fn success_args(args: fmt::Arguments) {
-
    println!("{} {args}", style("ok").green().reverse());
+
    println!("{} {args}", Paint::green("✓"));
}

pub fn tip_args(args: fmt::Arguments) {
-
    println!("{} {}", style("=>").blue(), style(format!("{args}")).dim());
+
    println!("👉 {}", style(format!("{args}")).italic());
}

-
pub fn width() -> Option<usize> {
-
    console::Term::stdout()
-
        .size_checked()
-
        .map(|(_, cols)| cols as usize)
+
pub fn columns() -> Option<usize> {
+
    termion::terminal_size().map(|(cols, _)| cols as usize).ok()
}

pub fn headline(headline: &str) {
@@ -66,7 +79,7 @@ pub fn headline(headline: &str) {

pub fn header(header: &str) {
    println!();
-
    println!("{}", style(format::yellow(header)).bold().underlined());
+
    println!("{}", style(format::yellow(header)).bold().underline());
    println!();
}

@@ -95,9 +108,9 @@ pub fn help(name: &str, version: &str, description: &str, usage: &str) {
pub fn usage(name: &str, usage: &str) {
    println!(
        "{} {}\n{}",
-
        style("==").red(),
-
        style(format!("Error: rad-{name}: invalid usage")).red(),
-
        style(prefixed(TAB, usage)).red().dim()
+
        ERROR_PREFIX,
+
        Paint::red(format!("Error: rad-{name}: invalid usage")),
+
        Paint::red(prefixed(TAB, usage)).dim()
    );
}

@@ -115,57 +128,39 @@ pub fn subcommand(msg: impl fmt::Display) {

pub fn warning(warning: &str) {
    println!(
-
        "{} {} {}",
-
        style("**").yellow(),
-
        style("Warning:").yellow().bold(),
-
        style(warning).yellow()
+
        "{} {} {warning}",
+
        Paint::yellow("!"),
+
        Paint::yellow("Warning:").bold(),
    );
}

pub fn error(error: impl fmt::Display) {
-
    println!("{} {}", style("==").red(), style(error).red());
+
    println!("{ERROR_PREFIX} {error}");
}

pub fn fail(header: &str, error: &anyhow::Error) {
    let err = error.to_string();
    let err = err.trim_end();
-
    let separator = if err.len() > 160 || err.contains('\n') {
-
        "\n"
-
    } else {
-
        " "
-
    };
+
    let separator = if err.contains('\n') { ":\n" } else { ": " };

    println!(
-
        "{} {}{}{}",
-
        style("==").red(),
-
        style(header).red().reverse(),
-
        separator,
-
        style(error).red().bold(),
+
        "{ERROR_PREFIX} {}{}{error}",
+
        Paint::red(header).bold(),
+
        Paint::red(separator),
    );

-
    let cause = error.root_cause();
-
    if cause.to_string() != error.to_string() {
-
        println!(
-
            "{} {}",
-
            style("==").red().dim(),
-
            style(error.root_cause()).red().dim()
-
        );
-
        blank();
-
    }
-

    if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
-
        println!("{} {}", style("==").yellow(), style(hint).yellow(),);
+
        println!("{} {}", Paint::yellow("×"), Paint::yellow(hint));
        blank();
    }
}

pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
-
    dialoguer::Confirm::new()
-
        .with_prompt(format!("{} {}", style(" ⤷".to_owned()).cyan(), prompt))
-
        .wait_for_newline(false)
-
        .default(true)
-
        .default(default)
-
        .interact()
+
    let prompt = format!("{} {}", Paint::blue("?".to_owned()), prompt);
+

+
    Confirm::new(&prompt)
+
        .with_default(default)
+
        .prompt()
        .unwrap_or_default()
}

@@ -182,209 +177,95 @@ pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
    if let Ok(signer) = profile.signer() {
        return Ok(signer);
    }
-

-
    let passphrase = secret_input();
+
    let passphrase = passphrase()?;
    let spinner = spinner("Unsealing key...");
    let signer = MemorySigner::load(&profile.keystore, passphrase)?;

    spinner.finish();
-
    Ok(signer.boxed())
-
}

-
pub fn theme() -> ColorfulTheme {
-
    ColorfulTheme {
-
        success_prefix: style("ok".to_owned()).for_stderr().green().reverse(),
-
        prompt_prefix: style(" ⤷".to_owned()).cyan().dim().for_stderr(),
-
        prompt_suffix: style("·".to_owned()).cyan().for_stderr(),
-
        prompt_style: Style::new().cyan().bold().for_stderr(),
-
        active_item_style: Style::new().for_stderr().yellow().reverse(),
-
        active_item_prefix: style("*".to_owned()).yellow().for_stderr(),
-
        picked_item_prefix: style("*".to_owned()).yellow().for_stderr(),
-
        inactive_item_prefix: style(" ".to_string()).for_stderr(),
-
        inactive_item_style: Style::new().yellow().for_stderr(),
-
        error_prefix: style("⤹  Error:".to_owned()).red().for_stderr(),
-
        success_suffix: style("·".to_owned()).cyan().for_stderr(),
-

-
        ..ColorfulTheme::default()
-
    }
+
    Ok(signer.boxed())
}

-
pub fn text_input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
+
pub fn input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
where
    S: fmt::Display + std::str::FromStr<Err = E> + Clone,
    E: fmt::Debug + fmt::Display,
{
-
    let theme = theme();
-
    let mut input: Input<S> = Input::with_theme(&theme);
-

+
    let input = CustomType::<S>::new(message).with_render_config(*CONFIG);
    let value = match default {
-
        Some(default) => input
-
            .with_prompt(message)
-
            .with_initial_text(default.to_string())
-
            .interact_text()?,
-
        None => input.with_prompt(message).interact_text()?,
+
        Some(default) => input.with_default(default).prompt()?,
+
        None => input.prompt()?,
    };
    Ok(value)
}

-
#[derive(Debug, Default, Clone)]
-
pub struct Optional<T> {
-
    option: Option<T>,
-
}
-

-
impl<T: fmt::Display> fmt::Display for Optional<T> {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        if let Some(val) = &self.option {
-
            write!(f, "{val}")
-
        } else {
-
            write!(f, "")
-
        }
-
    }
-
}
-

-
impl<T: FromStr> FromStr for Optional<T> {
-
    type Err = <T as FromStr>::Err;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        if s.is_empty() {
-
            return Ok(Optional { option: None });
-
        }
-
        let val: T = s.parse()?;
-

-
        Ok(Self { option: Some(val) })
+
pub fn passphrase() -> Result<Passphrase, anyhow::Error> {
+
    if let Some(p) = profile::env::passphrase() {
+
        Ok(p)
+
    } else {
+
        Ok(Passphrase::from(
+
            Password::new("Passphrase:")
+
                .with_render_config(*CONFIG)
+
                .with_display_mode(inquire::PasswordDisplayMode::Masked)
+
                .without_confirmation()
+
                .prompt()?,
+
        ))
    }
}

-
pub fn text_input_optional<S, E>(
-
    message: &str,
-
    initial: Option<String>,
-
) -> anyhow::Result<Option<S>>
-
where
-
    S: fmt::Display + fmt::Debug + FromStr<Err = E> + Clone,
-
    E: fmt::Debug + fmt::Display,
-
{
-
    let theme = theme();
-
    let mut input: Input<Optional<S>> = Input::with_theme(&theme);
-

-
    if let Some(init) = initial {
-
        input.with_initial_text(init);
+
pub fn passphrase_confirm() -> Result<Passphrase, anyhow::Error> {
+
    if let Some(p) = profile::env::passphrase() {
+
        Ok(p)
+
    } else {
+
        Ok(Passphrase::from(
+
            Password::new("Passphrase:")
+
                .with_render_config(*CONFIG)
+
                .with_display_mode(inquire::PasswordDisplayMode::Masked)
+
                .with_custom_confirmation_message("Repeat passphrase:")
+
                .with_custom_confirmation_error_message("The passphrases don't match.")
+
                .prompt()?,
+
        ))
    }
-
    let value = input
-
        .with_prompt(message)
-
        .allow_empty(true)
-
        .interact_text()?;
-

-
    Ok(value.option)
}

-
pub fn secret_input() -> Passphrase {
-
    secret_input_with_prompt("Passphrase")
-
}
-

-
// TODO: This prompt shows success just for entering a password,
-
// even if the password is later found out to be wrong.
-
// We should handle this differently.
-
pub fn secret_input_with_prompt(prompt: &str) -> Passphrase {
-
    Passphrase::from(
-
        Password::with_theme(&theme())
-
            .allow_empty_password(true)
-
            .with_prompt(prompt)
-
            .interact()
-
            .unwrap(),
-
    )
-
}
-

-
pub fn secret_input_with_confirmation() -> Passphrase {
-
    Passphrase::from(
-
        Password::with_theme(&theme())
-
            .with_prompt("Passphrase")
-
            .with_confirmation("Repeat passphrase", "Error: the passphrases don't match.")
-
            .interact()
-
            .unwrap(),
-
    )
-
}
-

-
pub fn secret_stdin() -> Result<Passphrase, anyhow::Error> {
+
pub fn passphrase_stdin() -> Result<Passphrase, anyhow::Error> {
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;

    Ok(Passphrase::from(input.trim_end().to_owned()))
}

-
pub fn read_passphrase(stdin: bool, confirm: bool) -> Result<Passphrase, anyhow::Error> {
-
    let passphrase = match profile::env::read_passphrase() {
-
        Some(input) => input,
-
        None => {
-
            if stdin {
-
                secret_stdin()?
-
            } else if confirm {
-
                secret_input_with_confirmation()
-
            } else {
-
                secret_input()
-
            }
-
        }
-
    };
-

-
    Ok(passphrase)
-
}
-

-
pub fn select<'a, T>(options: &'a [T], active: &'a T) -> Option<&'a T>
+
pub fn select<'a, T>(
+
    prompt: &str,
+
    options: &'a [T],
+
    active: &'a T,
+
) -> Result<Option<&'a T>, InquireError>
where
    T: fmt::Display + Eq + PartialEq,
{
-
    let theme = theme();
    let active = options.iter().position(|o| o == active);
-
    let mut selection = dialoguer::Select::with_theme(&theme);
+
    let selection =
+
        Select::new(prompt, options.iter().collect::<Vec<_>>()).with_render_config(*CONFIG);

    if let Some(active) = active {
-
        selection.default(active);
+
        selection.with_starting_cursor(active).prompt_skippable()
+
    } else {
+
        selection.prompt_skippable()
    }
-
    let result = selection
-
        .items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
-
        .interact_opt()
-
        .unwrap();
-

-
    result.map(|i| &options[i])
}

-
pub fn select_with_prompt<'a, T>(prompt: &str, options: &'a [T], active: &'a T) -> Option<&'a T>
-
where
-
    T: fmt::Display + Eq + PartialEq,
-
{
-
    let theme = theme();
-
    let active = options.iter().position(|o| o == active);
-
    let mut selection = dialoguer::Select::with_theme(&theme);
-

-
    selection.with_prompt(prompt);
-

-
    if let Some(active) = active {
-
        selection.default(active);
-
    }
-
    let result = selection
-
        .items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
-
        .interact_opt()
-
        .unwrap();
-

-
    result.map(|i| &options[i])
-
}
-

-
pub fn comment_select(issue: &Issue) -> Option<CommentId> {
-
    let selection = dialoguer::Select::with_theme(&theme())
-
        .with_prompt("Which comment do you want to react to?")
-
        .item(issue.description().unwrap_or_default())
-
        .items(
-
            &issue
-
                .comments()
-
                .map(|(_, i)| i.body().to_owned())
-
                .collect::<Vec<_>>(),
-
        )
-
        .default(0)
-
        .interact_opt()
-
        .unwrap();
+
pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> {
+
    let comments = issue.comments().collect::<Vec<_>>();
+
    let selection = Select::new(
+
        "Which comment do you want to react to?",
+
        (0..comments.len()).collect(),
+
    )
+
    .with_render_config(*CONFIG)
+
    .with_formatter(&|i| comments[i.index].1.body().to_owned())
+
    .prompt()
+
    .ok()?;

-
    selection
-
        .and_then(|n| issue.comments().nth(n))
-
        .map(|(id, _)| *id)
+
    comments.get(selection).copied()
}

pub fn markdown(content: &str) {
@@ -393,10 +274,6 @@ pub fn markdown(content: &str) {
    }
}

-
fn _info(args: std::fmt::Arguments) {
-
    println!("{args}");
-
}
-

pub mod proposal {
    use std::fmt::Write as _;

@@ -406,44 +283,44 @@ pub mod proposal {
        identity::Identity,
    };

-
    use super::{super::format, theme};
+
    use super::*;
+
    use crate::terminal::format;

    pub fn revision_select(
        proposal: &Proposal,
    ) -> Option<(&identity::RevisionId, &identity::Revision)> {
-
        let selection = dialoguer::Select::with_theme(&theme())
-
            .with_prompt("Which revision do you want to select?")
-
            .items(
-
                &proposal
-
                    .revisions()
-
                    .map(|(rid, _)| rid.to_string())
-
                    .collect::<Vec<_>>(),
-
            )
-
            .default(0)
-
            .interact_opt()
-
            .unwrap();
-

-
        selection.and_then(|n| proposal.revisions().nth(n))
+
        let revisions = proposal.revisions().collect::<Vec<_>>();
+
        let selection = Select::new(
+
            "Which revision do you want to select?",
+
            (0..revisions.len()).collect(),
+
        )
+
        .with_vim_mode(true)
+
        .with_formatter(&|ix| revisions[ix.index].0.to_string())
+
        .with_render_config(*CONFIG)
+
        .prompt()
+
        .ok()?;
+

+
        revisions.get(selection).copied()
    }

    pub fn revision_commit_select<'a>(
        proposal: &'a Proposal,
        previous: &'a Identity<Oid>,
    ) -> Option<(&'a identity::RevisionId, &'a identity::Revision)> {
-
        let selection = dialoguer::Select::with_theme(&theme())
-
            .with_prompt("Which revision do you want to commit?")
-
            .items(
-
                &proposal
-
                    .revisions()
-
                    .filter(|(_, r)| r.is_quorum_reached(previous))
-
                    .map(|(rid, _)| rid.to_string())
-
                    .collect::<Vec<_>>(),
-
            )
-
            .default(0)
-
            .interact_opt()
-
            .unwrap();
-

-
        selection.and_then(|n| proposal.revisions().nth(n))
+
        let revisions = proposal
+
            .revisions()
+
            .filter(|(_, r)| r.is_quorum_reached(previous))
+
            .collect::<Vec<_>>();
+
        let selection = Select::new(
+
            "Which revision do you want to commit?",
+
            (0..revisions.len()).collect(),
+
        )
+
        .with_formatter(&|ix| revisions[ix.index].0.to_string())
+
        .with_render_config(*CONFIG)
+
        .prompt()
+
        .ok()?;
+

+
        revisions.get(selection).copied()
    }

    pub fn diff(proposal: &identity::Revision, previous: &Identity<Oid>) -> anyhow::Result<String> {
modified radicle-cli/src/terminal/patch.rs
@@ -1,6 +1,7 @@
use radicle::git;

use crate::terminal as term;
+
use crate::terminal::cell::Cell as _;

/// The user supplied `Patch` description.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -18,15 +19,14 @@ impl Message {
    pub fn get(self, help: &str) -> String {
        let comment = match self {
            Message::Edit => term::Editor::new()
-
                .require_save(true)
-
                .trim_newlines(true)
-
                .extension(".markdown")
+
                .extension("markdown")
                .edit(help)
-
                .unwrap(),
+
                .ok()
+
                .flatten(),
            Message::Blank => None,
            Message::Text(c) => Some(c),
        };
-
        let comment = comment.unwrap_or_default().replace(help, "");
+
        let comment = comment.unwrap_or_default();
        let comment = comment.trim();

        comment.to_owned()
@@ -57,7 +57,7 @@ pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> {
            .unwrap_or_else(|| commit.message_bytes());
        table.push([
            term::format::secondary(term::format::oid(commit.id())),
-
            term::format::italic(String::from_utf8_lossy(message)),
+
            term::format::italic(String::from_utf8_lossy(message).to_string()),
        ]);
    }
    table.render();
@@ -100,6 +100,6 @@ pub fn print_title_desc(title: &str, description: &str) {
    term::blank();
    term::print(term::format::dim(format!(
        "╰{}",
-
        "─".repeat(term::text_width(title_pretty) - 1)
+
        "─".repeat(title_pretty.to_string().width() - 1)
    )));
}
modified radicle-cli/src/terminal/spinner.rs
@@ -1,77 +1,171 @@
-
use dialoguer::console::style;
-
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
+
use std::io::Write;
+
use std::mem::ManuallyDrop;
+
use std::sync::{Arc, Mutex};
+
use std::{fmt, io, thread, time};

-
use crate::terminal as term;
+
use crate::terminal::io::ERROR_PREFIX;
+
use crate::terminal::Paint;

+
/// How much time to wait between spinner animation updates.
+
pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99);
+
/// The spinner animation strings.
+
pub const DEFAULT_STYLE: [Paint<&'static str>; 4] = [
+
    Paint::magenta("◢"),
+
    Paint::cyan("◣"),
+
    Paint::magenta("◤"),
+
    Paint::blue("◥"),
+
];
+

+
struct Progress {
+
    state: State,
+
    message: Paint<String>,
+
}
+

+
impl Progress {
+
    fn new(message: Paint<String>) -> Self {
+
        Self {
+
            state: State::Running { cursor: 0 },
+
            message,
+
        }
+
    }
+
}
+

+
enum State {
+
    Running { cursor: usize },
+
    Canceled,
+
    Done,
+
    Error,
+
}
+

+
/// A progress spinner.
pub struct Spinner {
-
    progress: ProgressBar,
-
    message: String,
+
    progress: Arc<Mutex<Progress>>,
+
    handle: ManuallyDrop<thread::JoinHandle<()>>,
}

impl Drop for Spinner {
    fn drop(&mut self) {
-
        // TODO: Set error that will be output on fail.
-
        if !self.progress.is_finished() {
-
            self.set_failed();
+
        if let Ok(mut progress) = self.progress.lock() {
+
            if let State::Running { .. } = progress.state {
+
                progress.state = State::Canceled;
+
            }
        }
+
        unsafe { ManuallyDrop::take(&mut self.handle) }
+
            .join()
+
            .unwrap();
    }
}

impl Spinner {
-
    pub fn finish(&self) {
-
        self.progress.finish_and_clear();
-
        term::success!("{}", &self.message);
+
    /// Mark the spinner as successfully completed.
+
    pub fn finish(self) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.state = State::Done;
+
        }
    }

-
    pub fn done(self) {
-
        self.progress.finish_and_clear();
-
        term::info!("{}", &self.message);
+
    /// Mark the spinner as failed. This cancels the spinner.
+
    pub fn failed(self) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.state = State::Error;
+
        }
    }

-
    pub fn failed(mut self) {
-
        self.set_failed();
+
    /// Cancel the spinner with an error.
+
    pub fn error(self, msg: impl fmt::Display) {
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.state = State::Error;
+
            progress.message = Paint::new(format!(
+
                "{} {} {}",
+
                progress.message,
+
                Paint::red("error:"),
+
                msg
+
            ));
+
        }
    }

-
    pub fn error(mut self, msg: impl ToString) {
+
    /// Set the spinner's message.
+
    pub fn message(&mut self, msg: impl fmt::Display) {
        let msg = msg.to_string();

-
        self.message = format!("{} error: {}", self.message, msg);
-
        self.set_failed();
+
        if let Ok(mut progress) = self.progress.lock() {
+
            progress.message = Paint::new(msg);
+
        }
    }
+
}

-
    pub fn clear(self) {
-
        self.progress.finish_and_clear();
-
    }
+
/// Create a new spinner with the given message.
+
pub fn spinner(message: impl ToString) -> Spinner {
+
    let message = message.to_string();
+
    let progress = Arc::new(Mutex::new(Progress::new(Paint::new(message))));
+
    let handle = thread::spawn({
+
        let progress = progress.clone();

-
    pub fn message(&mut self, msg: impl ToString) {
-
        let msg = msg.to_string();
+
        move || {
+
            let mut stdout = io::stdout();
+
            let mut stderr = termion::cursor::HideCursor::from(io::stderr());

-
        self.progress.set_message(msg.clone());
-
        self.message = msg;
-
    }
+
            loop {
+
                let Ok(mut progress) = progress.lock() else {
+
                    break;
+
                };
+
                match &mut *progress {
+
                    Progress {
+
                        state: State::Running { cursor },
+
                        message,
+
                    } => {
+
                        let spinner = DEFAULT_STYLE[*cursor];

-
    pub fn set_failed(&mut self) {
-
        self.progress.finish_and_clear();
-
        term::println(style("!!").red().reverse(), &self.message);
-
    }
-
}
+
                        write!(
+
                            stderr,
+
                            "{}{}{spinner} {message}",
+
                            termion::cursor::Save,
+
                            termion::clear::AfterCursor,
+
                        )
+
                        .ok();

-
pub fn spinner(message: impl ToString) -> Spinner {
-
    let message = message.to_string();
-
    let style = ProgressStyle::default_spinner()
-
        .tick_strings(&[
-
            &style("\\ ").yellow().to_string(),
-
            &style("| ").yellow().to_string(),
-
            &style("/ ").yellow().to_string(),
-
            &style("| ").yellow().to_string(),
-
        ])
-
        .template("{spinner} {msg}")
-
        .on_finish(ProgressFinish::AndClear);
-

-
    let progress = ProgressBar::new(!0);
-
    progress.set_style(style);
-
    progress.enable_steady_tick(99);
-
    progress.set_message(message.clone());
-

-
    Spinner { message, progress }
+
                        write!(stderr, "{}", termion::cursor::Restore).ok();
+

+
                        *cursor += 1;
+
                        *cursor %= DEFAULT_STYLE.len();
+
                    }
+
                    Progress {
+
                        state: State::Done,
+
                        message,
+
                    } => {
+
                        write!(stderr, "{}", termion::clear::AfterCursor).ok();
+
                        writeln!(stdout, "{} {message}", Paint::green("✓")).ok();
+
                        break;
+
                    }
+
                    Progress {
+
                        state: State::Canceled,
+
                        message,
+
                    } => {
+
                        write!(stderr, "{}", termion::clear::AfterCursor).ok();
+
                        writeln!(
+
                            stdout,
+
                            "{ERROR_PREFIX} {message} {}",
+
                            Paint::red("<canceled>")
+
                        )
+
                        .ok();
+
                        break;
+
                    }
+
                    Progress {
+
                        state: State::Error,
+
                        message,
+
                    } => {
+
                        writeln!(stdout, "{ERROR_PREFIX} {message}").ok();
+
                        break;
+
                    }
+
                }
+
                drop(progress);
+
                thread::sleep(DEFAULT_TICK);
+
            }
+
        }
+
    });
+

+
    Spinner {
+
        progress,
+
        handle: ManuallyDrop::new(handle),
+
    }
}
modified radicle-cli/src/terminal/table.rs
@@ -9,18 +9,31 @@
//! t.push(["aphid", "lacewing"]);
//! t.push(["spider mite", "ladybug"]);
//! t.render();
-
//! // pest        biological control
-
//! // aphid       ladybug
-
//! // spider mite persimilis
//! ```
-

-
use std::fmt::Write;
+
//! Output:
+
//! ``` plain
+
//! pest        biological control
+
//! aphid       ladybug
+
//! spider mite persimilis
+
//! ```
+
use std::io;

use crate::terminal as term;
+
use crate::terminal::cell::Cell;
+

+
/// Used to specify maximum width or height.
+
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
+
pub struct Max {
+
    width: Option<usize>,
+
    height: Option<usize>,
+
}

#[derive(Debug, Default)]
pub struct TableOptions {
+
    /// Whether the table should be allowed to overflow.
    pub overflow: bool,
+
    /// The maximum width and height.
+
    pub max: Max,
}

#[derive(Debug)]
@@ -49,16 +62,20 @@ impl<const W: usize> Table<W> {
        }
    }

-
    pub fn push(&mut self, row: [impl ToString; W]) {
+
    pub fn push(&mut self, row: [impl Cell; W]) {
        let row = row.map(|s| s.to_string());
        for (i, cell) in row.iter().enumerate() {
-
            self.widths[i] = self.widths[i].max(console::measure_text_width(cell));
+
            self.widths[i] = self.widths[i].max(cell.width());
        }
        self.rows.push(row);
    }

    pub fn render(self) {
-
        let width = term::width(); // Terminal width.
+
        self.write(io::stdout()).ok();
+
    }
+

+
    pub fn write<T: io::Write>(self, mut writer: T) -> io::Result<()> {
+
        let width = self.opts.max.width.or_else(term::columns);

        for row in &self.rows {
            let mut output = String::new();
@@ -66,27 +83,25 @@ impl<const W: usize> Table<W> {

            for (i, cell) in row.iter().enumerate() {
                if i == cells - 1 || self.opts.overflow {
-
                    write!(output, "{cell}").ok();
+
                    output.push_str(cell.to_string().as_str());
                } else {
-
                    write!(
-
                        output,
-
                        "{} ",
-
                        console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
-
                    )
-
                    .ok();
+
                    output.push_str(cell.pad(self.widths[i]).as_str());
+
                    output.push(' ');
                }
            }

            let output = output.trim_end();
-
            println!(
+
            writeln!(
+
                writer,
                "{}",
                if let Some(width) = width {
-
                    console::truncate_str(output, width - 1, "…")
+
                    output.truncate(width, "…")
                } else {
                    output.into()
                }
-
            );
+
            )?;
        }
+
        Ok(())
    }

    pub fn render_tree(self) {
@@ -97,12 +112,116 @@ impl<const W: usize> Table<W> {
                print!("└── ");
            }
            for (i, cell) in row.iter().enumerate() {
-
                print!(
-
                    "{} ",
-
                    console::pad_str(cell, self.widths[i], console::Alignment::Left, None)
-
                );
+
                print!("{} ", cell.pad(self.widths[i]));
            }
            println!();
        }
    }
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+
    use pretty_assertions::assert_eq;
+

+
    #[test]
+
    fn test_truncate() {
+
        assert_eq!("🍍".truncate(1, "…"), String::from("…"));
+
        assert_eq!("🍍".truncate(1, ""), String::from(""));
+
        assert_eq!("🍍🍍".truncate(2, "…"), String::from("…"));
+
        assert_eq!("🍍🍍".truncate(3, "…"), String::from("🍍…"));
+
        assert_eq!("🍍".truncate(1, "🍎"), String::from(""));
+
        assert_eq!("🍍".truncate(2, "🍎"), String::from("🍍"));
+
        assert_eq!("🍍🍍".truncate(3, "🍎"), String::from("🍎"));
+
        assert_eq!("🍍🍍🍍".truncate(4, "🍎"), String::from("🍍🍎"));
+
        assert_eq!("hello".truncate(3, "…"), String::from("he…"));
+
    }
+

+
    #[test]
+
    fn test_table() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions::default());
+

+
        t.push(["pineapple", "rosemary"]);
+
        t.push(["apples", "pears"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "pineapple rosemary\n",
+
                "apples    pears\n"
+
            ].join("")
+
        );
+
    }
+

+
    #[test]
+
    fn test_table_truncate() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions {
+
            max: Max {
+
                width: Some(16),
+
                height: None,
+
            },
+
            ..TableOptions::default()
+
        });
+

+
        t.push(["pineapple", "rosemary"]);
+
        t.push(["apples", "pears"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "pineapple rosem…\n",
+
                "apples    pears\n"
+
            ].join("")
+
        );
+
    }
+

+
    #[test]
+
    fn test_table_unicode() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions::default());
+

+
        t.push(["🍍pineapple", "__rosemary", "__sage"]);
+
        t.push(["__pears", "🍎apples", "🍌bananas"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "🍍pineapple __rosemary __sage\n",
+
                "__pears     🍎apples   🍌bananas\n"
+
            ].join("")
+
        );
+
    }
+

+
    #[test]
+
    fn test_table_unicode_truncate() {
+
        let mut s = Vec::new();
+
        let mut t = Table::new(TableOptions {
+
            max: Max {
+
                width: Some(16),
+
                height: None,
+
            },
+
            ..TableOptions::default()
+
        });
+

+
        t.push(["🍍pineapple", "__rosemary"]);
+
        t.push(["__pears", "🍎apples"]);
+
        t.write(&mut s).unwrap();
+

+
        #[rustfmt::skip]
+
        assert_eq!(
+
            String::from_utf8_lossy(&s),
+
            [
+
                "🍍pineapple __r…\n",
+
                "__pears     🍎a…\n"
+
            ].join("")
+
        );
+
    }
+
}
modified radicle-cli/src/terminal/textbox.rs
@@ -1,6 +1,7 @@
use std::fmt;

use crate::terminal as term;
+
use crate::terminal::cell::Cell as _;

pub struct TextBox {
    pub body: String,
@@ -32,14 +33,8 @@ impl TextBox {

impl fmt::Display for TextBox {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        let mut width = self
-
            .body
-
            .lines()
-
            .map(console::measure_text_width)
-
            .max()
-
            .unwrap_or(0)
-
            + 2;
-
        if let Some(max) = term::width() {
+
        let mut width = self.body.lines().map(|l| l.width()).max().unwrap_or(0) + 2;
+
        if let Some(max) = term::columns() {
            if width + 2 > max {
                width = max - 2
            }
@@ -53,11 +48,7 @@ impl fmt::Display for TextBox {
        writeln!(f, "┌{}{}┐", connector, "─".repeat(header_width))?;

        for l in self.body.lines() {
-
            writeln!(
-
                f,
-
                "│ {}│",
-
                console::pad_str(l, width - 1, console::Alignment::Left, Some("…"))
-
            )?;
+
            writeln!(f, "│ {}│", l.pad(width - 1))?;
        }

        let (connector, footer_width) = if !self.last {
modified radicle-tools/Cargo.toml
@@ -13,6 +13,10 @@ git-ref-format = { version = "0", features = ["serde", "macro"] }
version = "0"
path = "../radicle"

+
[dependencies.radicle-cli]
+
version = "0"
+
path = "../radicle-cli"
+

[[bin]]
name = "rad-init"
path = "src/rad-init.rs"
@@ -32,3 +36,7 @@ path = "src/rad-push.rs"
[[bin]]
name = "rad-agent"
path = "src/rad-agent.rs"
+

+
[[bin]]
+
name = "rad-cli-demo"
+
path = "src/rad-cli-demo.rs"
added radicle-tools/src/rad-cli-demo.rs
@@ -0,0 +1,74 @@
+
use std::{thread, time};
+

+
use radicle_cli::terminal;
+

+
fn main() -> anyhow::Result<()> {
+
    let demo = terminal::io::select(
+
        "Choose something to try out:",
+
        &[
+
            "spinner",
+
            "spinner-drop",
+
            "spinner-error",
+
            "editor",
+
            "prompt",
+
        ],
+
        &"spinner",
+
    )?;
+

+
    match demo {
+
        Some(&"editor") => {
+
            let output = terminal::editor::Editor::new()
+
                .extension("rs")
+
                .edit("// Enter code here.");
+

+
            match output {
+
                Ok(Some(s)) => {
+
                    terminal::info!("You entered:");
+
                    terminal::blob(s);
+
                }
+
                Ok(None) => {
+
                    terminal::info!("You didn't enter anything.");
+
                }
+
                Err(e) => {
+
                    return Err(e.into());
+
                }
+
            }
+
        }
+
        Some(&"spinner") => {
+
            let mut spinner = terminal::spinner("Spinning turbines..");
+
            thread::sleep(time::Duration::from_secs(1));
+
            spinner.message("Still spinning..");
+
            thread::sleep(time::Duration::from_secs(1));
+
            spinner.message("Almost done..");
+
            thread::sleep(time::Duration::from_secs(1));
+
            spinner.message("Done.");
+

+
            spinner.finish();
+
        }
+
        Some(&"spinner-drop") => {
+
            let _spinner = terminal::spinner("Spinning turbines..");
+
            thread::sleep(time::Duration::from_secs(3));
+
        }
+
        Some(&"spinner-error") => {
+
            let spinner = terminal::spinner("Spinning turbines..");
+
            thread::sleep(time::Duration::from_secs(3));
+
            spinner.error("broken turbine");
+
        }
+
        Some(&"prompt") => {
+
            let fruit = terminal::io::select(
+
                "Enter your favorite fruit:",
+
                &["apple", "pear", "banana", "strawberry"],
+
                &"apple",
+
            )?;
+

+
            if let Some(fruit) = fruit {
+
                terminal::success!("You have chosen '{fruit}'");
+
            } else {
+
                terminal::info!("Ok, bye.");
+
            }
+
        }
+
        _ => {}
+
    }
+

+
    Ok(())
+
}
modified radicle/src/identity/project.rs
@@ -178,12 +178,12 @@ impl Project {
    }

    #[inline]
-
    pub fn name(&self) -> &String {
+
    pub fn name(&self) -> &str {
        &self.name
    }

    #[inline]
-
    pub fn description(&self) -> &String {
+
    pub fn description(&self) -> &str {
        &self.description
    }

modified radicle/src/profile.rs
@@ -34,7 +34,7 @@ pub mod env {
    /// Passphrase for the encrypted radicle secret key.
    pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";

-
    pub fn read_passphrase() -> Option<super::Passphrase> {
+
    pub fn passphrase() -> Option<super::Passphrase> {
        let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else {
            return None;
        };
@@ -109,7 +109,7 @@ impl Profile {
    }

    pub fn signer(&self) -> Result<Box<dyn Signer>, Error> {
-
        if let Some(passphrase) = env::read_passphrase() {
+
        if let Some(passphrase) = env::passphrase() {
            let signer = keystore::MemorySigner::load(&self.keystore, passphrase)?;
            return Ok(signer.boxed());
        }