This is my implementation for Elvish of the Chain prompt theme, based on the original fish theme at https://github.com/oh-my-fish/theme-chain.
Ported to Elvish by Diego Zamboni <diego@zzamboni.org>.
This file is written in literate programming style, to make it easy to explain. See chain.elv for the generated file.
To use this theme, first install the github.com/zzamboni/elvish-themes package using epm:
epm:install github.com/zzamboni/elvish-themes
You can do this interactively or from your ~/.elvish/rc.elv
file. If you want to put this in your rc.elv
so that the package is automatically installed when needed, and avoid having a message printed every time the shell starts, you can add the &silent-if-installed=$true
option to the above command.
Add the following to you ~/.elvish/rc.elv
file to load and configure the theme:
Note: You have to call chain:init
to set up the prompt, just loading the module does not configure it.
use github.com/zzamboni/elvish-themes/chain
chain:init
If you want the prompt to be shown using bold fonts, set the following:
chain:bold-prompt = $true
The default Elvish prompt settings work fine, but if you want to fine tune them, check the Prompts documentation. For example, if you want to keep the prompt from appearing inverted when “stale” (this may happen if you use git segments and are in a large git repo), you could set $edit:prompt-stale-transform
to an identity function:
edit:prompt-stale-transform = $all~
On the other hand, if you want the prompt to be grayed out when stale, you can do the following:
edit:prompt-stale-transform = { each [x]{ styled $x[text] "bright-black" } }
The prompt chains on both sides can be configured by assigning to theme:chain:prompt-segments
and theme:chain:rprompt-segments
, respectively. These variables must be arrays, and the given segments will be automatically linked by $theme:chain:glyph[chain]
. Their default values are:
var prompt-segments-defaults = [ su dir git-branch git-combined arrow ]
var rprompt-segments-defaults = [ ]
Each element can be any of the following:
- The name of one of the built-in segments. Available segments:
arrow
,timestamp
,su
,dir
,session
(a glyph with a unique color for the current session, based on its PID),git-branch
,git-dirty
,git-untracked
,git-ahead
,git-behind
,git-staged
,git-combined
(which combines in a single segment all the other git status indicators),git-timestamp
(timestamp of the last git commit); - A string or the output of styled, which will be displayed as-is;
- A lambda, which will be called and its output displayed;
- The output of a call to
chain:segment <style> <strings>
, which returns a “proper” segment, enclosed in square brackets and styled as requested.
Note: using any of the git-
segments will automatically start gitstatusd
(using the github.com/href/gitstatus library) the first time a prompt is shown. If you have git segments on both the left and right prompts, this can lead to a race condition in which two copies of gitstatusd
are started in parallel. To avoid this, you can manually start gitstatusd
after loading the theme, as follows:
use github.com/zzamboni/elvish-themes/chain
chain:gitstatus:start
You can customize the glyphs and styles used for the different segments by assigning to the corresponding elements of chain:glyph
and chain:segment-style
, respectively.
The value assigned to an element of chain:segment-style
can be:
- Any valid argument to styled.
- The string
default
to indicate no special color (this will show in your regular terminal text color). - The string
session
to produce a color based on the current process ID, which can be used to indicate a unique color for the current session. This is used by thesession
segment, but can be assigned to any segment.
You can change the segment delimiters from the default square brackets by assigning a new value to $chain:prompt-segment-delimiters
. Its value can be a two-character string (for single-character delimiters) or a two-element list (for multi-character or empty delimiters). Examples:
chain:prompt-segment-delimiters = "{}" # {segment}
chain:prompt-segment-delimiters = " " # segment
chain:prompt-segment-delimiters = [ "<<" ">>" ] # <<segment>>
chain:prompt-segment-delimiters = [ "" "" ] # segment
By default, all segments are separated by the chain
glyph. As a special case, you can specify whether the last segment should be separated by a chain or not by setting the $chain:show-last-chain
variable to $false
. This can be used to designate some special character in the arrow
segment glyph (e.g. a Unicode arrow) and have it shown without a separator. For example:
chain:show-last-chain = $false
chain:glyph[arrow] = "→"
Also by default, a space is added after the arrow
segment. You can supress this (for example, if the glyph you use for the arrow
segment already includes sufficient spacing) by setting $chain:space-after-arrow
to $false
:
chain:space-after-arrow = $false
This module also includes the chain:summary-status
function, which provides a status summary of git repositories, using the git-combined
, git-branch
and git-timestamp
segments. The list is presented in reverse chronological order according to their latest git commit (only if your version of Elvish supports the order
builtin). I use this to get a quick summary of the status of my most commonly-used repos. The repositories to display can be provided in mutliple ways (if more than one is specified, the first one found is used):
- Default behavior when no arguments nor options are given: read from a JSON file specified in
$chain:summary-repos-file
(default value:~/.elvish/package-data/elvish-themes/chain-summary-repos.json
). The contents of this file can be manipulated using thechain:add-summary-repo
andchain:remove-summary-repo
functions (see example below). - As arguments to the function, e.g.
chain:summary-status dir1 dir2
. - All the git repos inside your home directory:
chain:summary-status &all
. Note: this uses thefd
command by default, can be changed by storing the new function in$chain:find-all-user-repos
. Default value:chain:find-all-user-repos = { fd -H -I -t d '^.git$' ~ | each $path:dir~ }
- In combination with any of the above, the
&only-dirty
option can be used to only display repositories which are not clean.
You can add or remove directories to the list by using the chain:add-summary-repo
and chain:remove-summary-repo
functions. By default these functions add/remove the current directory, but you can also specify others. Example:
[~]─> cd ~/.elvish
[~/.elvish]─[⎇ master]─> chain:add-summary-repo
Repo /Users/taazadi1/.elvish added to the list
[~/.elvish]─[⎇ master]─> chain:add-summary-repo ~/.emacs.d ~/.hammerspoon
Repo /Users/taazadi1/.emacs.d added to the list
Repo /Users/taazadi1/.hammerspoon added to the list
[~/.elvish]─[⎇ master]─> chain:summary-status
[2020-05-25] [OK] [⎇ master] ~/.elvish
[2020-05-27] [OK] [⎇ master] ~/.emacs.d
[2020-05-22] [OK] [⎇ master] ~/.hammerspoon
[~/.elvish]─[⎇ master]─> chain:summary-status ~/.elvish/lib/github.com/zzamboni/*
[2020-05-09] [OK] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-completions
[2020-05-08] [OK] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-modules
[2020-05-22] [●] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-themes
[~/.elvish]─[⎇ master]─> chain:summary-status &only-dirty ~/.elvish/lib/github.com/zzamboni/*
[2020-05-22] [●] [⎇ master] ~/.elvish/lib/github.com/zzamboni/elvish-themes
By default, a progress indicator is shown while the repository data is being collected. You can disable this by setting $chain:summary-progress-indicator
to $false
. The indicator characters to show can be customized by storing a string or list in $chain:summary-progress-steps
.
Load the libraries we need.
use re
use str
use path
We use href’s gitstatus library for the git functions.
use github.com/href/elvish-gitstatus/gitstatus
use github.com/zzamboni/elvish-modules/spinners
Set up the default values for the chains (all can be configured by assigning to the appropriate variable):
var prompt-segments = $prompt-segments-defaults
var rprompt-segments = $rprompt-segments-defaults
Set up the default values for the glyphs used in the different chains. Note that some of the Unicode glyphs may need an extra space after them so that the character does not run into the next one in the terminal. This is highly dependent on the font you use, so please fine tune as needed. The default values work fine for the Inconsolata font I use.
var default-glyph = [
&git-branch= "⎇"
&git-dirty= "●"
&git-ahead= "⬆"
&git-behind= "⬇"
&git-staged= "✔"
&git-untracked= "+"
&git-deleted= "-"
&su= "⚡"
&chain= "─"
&session= "▪"
&arrow= ">"
]
Styling for each built-in segment. The value must be a valid argument to styled.
var default-segment-style = [
&git-branch= [ blue ]
&git-dirty= [ yellow ]
&git-ahead= [ red ]
&git-behind= [ red ]
&git-staged= [ green ]
&git-untracked= [ red ]
&git-deleted= [ red ]
&git-combined= [ default ]
&git-timestamp= [ cyan ]
&git-repo= [ blue ]
&su= [ yellow ]
&chain= [ default ]
&arrow= [ green ]
&dir= [ cyan ]
&session= [ session ]
×tamp= [ bright-black ]
]
The $glyph
and $segment-style
maps are where the user can assign their custom glyphs or styles. Both are empty by default. If an element does not exist in these variables, the corresponding default value is used.
var glyph = [&]
var segment-style = [&]
To how many letters to abbreviate directories in the path - 0 to show in full.
var prompt-pwd-dir-length = 1
Format to use for the timestamp
segment, in strftime(3) format.
var timestamp-format = "%R"
User ID that will trigger the su
segment. Defaults to root (UID 0).
var root-id = 0
Whether the prompt should be bold.
var bold-prompt = $false
Whether the last segment should be separated by a chain or not. This can be used to designate some special character in the arrow
segment and have it shown without a separator.
var show-last-chain = $true
Whether a space should be added at the end of the arrow segment. Defaults to $true
, but you may want to set it to $false
depending on the characters you use for the arrow segment.
var space-after-arrow = $true
The git-get-timestamp
function gets executed to produce the text to be displayed in the git-timestamp
module. You can change it if you want to change the format of what gets displayed.
var git-get-timestamp = { git log -1 --date=short --pretty=format:%cd }
The prompt-segment-delimiters
variable contains the “before” and “after” strings to be used in enclosing each prompt segment. By default each segment is enclosed in brackets. Note that for single-character delimiters you can use a two-character string like in the default value, but you could also use a list with two elements, in this case you can have multi-character delimiters, like in the commented-out example.
var prompt-segment-delimiters = "[]"
# prompt-segment-delimiters = [ "<<" ">>" ]
Function to choose a color based on the current value of $pid
, as an indicator of the current session.
fn -session-color {
var valid-colors = [ red green yellow blue magenta cyan white bright-black bright-red bright-green bright-yellow bright-blue bright-magenta bright-cyan bright-white ]
put $valid-colors[(% $pid (count $valid-colors))]
}
Internal function to return a styled string, or plain if color
is “default”. If $color
is “session”, then a unique color is chosen for the current session using the -session-color
function.
fn -colorized {|what @color|
if (and (not-eq $color []) (eq (kind-of $color[0]) list)) {
set color = [(all $color[0])]
}
if (and (not-eq $color [default]) (not-eq $color [])) {
if (eq $color [session]) {
set color = [(-session-color)]
}
if $bold-prompt {
set color = [ $@color bold ]
}
styled $what $@color
} else {
put $what
}
}
We have two auxiliary functions to return the glyph or style corresponding to a given segment. Default values are stored in the module’s $default-glyph
and $default-segment-style
variables, but the user can provide their own values by setting $glyph
and $segment-style
respectively.
fn -glyph {|segment-name|
if (has-key $glyph $segment-name) {
put $glyph[$segment-name]
} else {
put $default-glyph[$segment-name]
}
}
fn -segment-style {|segment-name|
if (has-key $segment-style $segment-name) {
put $segment-style[$segment-name]
} else {
put $default-segment-style[$segment-name]
}
}
The -colorized-glyph
returns the glyph for the given segment, with its corresponding style. If extra arguments are given, they are concatenated after the glyph.
fn -colorized-glyph {|segment-name @extra-text|
-colorized (-glyph $segment-name)(str:join "" $extra-text) (-segment-style $segment-name)
}
Build a prompt segment in the given style, surrounded by square brackets. The first argument can be a style argument understood by styled
, or the name of one of the predefined segments. In the latter case, the style is taken from the $segment-style
map, and if a glyph for that segment name exists in the $glyph
map, it is automatically prepended to the given text.
fn prompt-segment {|segment-or-style @texts|
var style = $segment-or-style
if (or (has-key $default-segment-style $segment-or-style) (has-key $segment-style $segment-or-style)) {
set style = (-segment-style $segment-or-style)
}
if (or (has-key $default-glyph $segment-or-style) (has-key $glyph $segment-or-style)) {
set texts = [ (-glyph $segment-or-style) $@texts ]
}
var text = $prompt-segment-delimiters[0](str:join ' ' $texts)$prompt-segment-delimiters[1]
-colorized $text $style
}
This is where the built-in segments are defined. We assign the corresponding functions to elements of the $segment
map, indexed by their segment name. The segment names need to correspond between the $segment
, $glyph
and $segment-style
maps.
var segment = [&]
Note that all the git-related segment functions only produce an output if the current directory contains a git repository.
We define a module-level variable which contains the latest git information. It gets populated once-per-prompt by the -parse-git
function, and the information is used by all the segments.
var last-status = [&]
The -parse-git
function calls gitstatus:query
to get the git status of the current directory. It extends the results with the result from -any-staged
to have an easy indicator of staged files.
fn -parse-git {|&with-timestamp=$false|
set last-status = (gitstatus:query $pwd)
if $with-timestamp {
set last-status[timestamp] = ($git-get-timestamp)
}
}
The git-branch
segment indicates the current branch name. If we are in a detached-branch state, we return the first 6 digits of the commit ID.
set segment[git-branch] = {
var branch = $last-status[local-branch]
if (not-eq $branch $nil) {
if (eq $branch '') {
set branch = $last-status[commit][0..7]
}
prompt-segment git-branch $branch
}
}
The git-timestamp
segment shows the last-commit timestamp from the current branch.
set segment[git-timestamp] = {
var ts = $nil
if (has-key $last-status timestamp) {
set ts = $last-status[timestamp]
} else {
set ts = ($git-get-timestamp)
}
prompt-segment git-timestamp $ts
}
The -show-git-indicator
function takes a git segment name and returns whether it should be shown, depending on the information stored in $last-status
. Since the git segment names do not correspond one-to-one with the elements of $last-status
, we do here the mapping between them.
(note that for now, git-deleted
is the same as git-dirty
, since gitstatus
does not report deleted files separately, only as unstaged changes)
fn -show-git-indicator {|segment|
var status-name = [
&git-dirty= unstaged &git-staged= staged
&git-ahead= commits-ahead &git-untracked= untracked
&git-behind= commits-behind &git-deleted= unstaged
]
var value = $last-status[$status-name[$segment]]
# The indicator must show if the element is >0 or a non-empty list
if (eq (kind-of $value) list) {
not-eq $value []
} else {
and (not-eq $value $nil) (> $value 0)
}
}
Generic function to display a git prompt segment.
fn -git-prompt-segment {|segment|
if (-show-git-indicator $segment) {
prompt-segment $segment
}
}
We support the following git indicator segments:
(note that for now, git-deleted
still exists but is the same as git-dirty
, since gitstatus
does not report deleted files separately, only as unstaged changes, so it’s removed from the default list above)
#-git-indicator-segments = [untracked deleted dirty staged ahead behind]
var -git-indicator-segments = [untracked dirty staged ahead behind]
- The
git-dirty
segment indicates whether there are any local modifications (modified or deleted files). - The
git-ahead
andgit-behind
segments indicate whether the current repository is ahead or behind of the upstream remote, if any. - The
git-staged
,git-untracked
segments indicate whether there are staged-but-uncommited or untracked files, respectively.
Using -git-prompt-segment
, we define all these git segments.
each {|ind|
set segment[git-$ind] = { -git-prompt-segment git-$ind }
} $-git-indicator-segments
The git-combined
segment combines all the different status indicators in a single segment. The $segment-style[git-combined]
value determines the color used for the surrounding brackets.
set segment[git-combined] = {
var indicators = [(each {|ind|
if (-show-git-indicator git-$ind) { -colorized-glyph git-$ind }
} $-git-indicator-segments)]
if (> (count $indicators) 0) {
var color = (-segment-style git-combined)
put (-colorized $prompt-segment-delimiters[0] $color) $@indicators (-colorized $prompt-segment-delimiters[1] $color)
}
}
For this segment we also need a support function, which returns the current path with each directory name shortened to a maximum of $prompt-pwd-dir-length
characters.
fn -prompt-pwd {
var tmp = (tilde-abbr $pwd)
if (== $prompt-pwd-dir-length 0) {
put $tmp
} else {
re:replace '(\.?[^/]{'$prompt-pwd-dir-length'})[^/]*/' '$1/' $tmp
}
}
set segment[dir] = {
prompt-segment dir (-prompt-pwd)
}
This segment outputs a glyph if the current user has a privileged ID (root
by default, with ID 0, but can be configured by changing $root-id
). We precompute the UID since it cannot change in the middle of the session. This avoids calling the id
command on every prompt.
var uid = (id -u)
set segment[su] = {
if (eq $uid $root-id) {
prompt-segment su
}
}
This segment simply outputs the current date according to the format defined in $timestamp-format
.
set segment[timestamp] = {
prompt-segment timestamp (date +$timestamp-format)
}
This segment prints a session indicator in a color unique to the current session, based on its $pid
.
set segment[session] = {
prompt-segment session
}
This segment prints the separator between the other chains and the cursor. If $chain:space-after-arrow
is true
(its default value), a space is appended at the end.
set segment[arrow] = {
var end-text = ''
if $space-after-arrow { set end-text = ' ' }
-colorized-glyph arrow $end-text
}
Given a segment specification, return the appropriate value, depending on whether it’s the name of a built-in segment, a lambda, a string or a styled
object.
fn -interpret-segment {|seg|
var k = (kind-of $seg)
if (eq $k 'fn') {
# If it's a lambda, run it
$seg
} elif (eq $k 'string') {
if (has-key $segment $seg) {
# If it's the name of a built-in segment, run its function
$segment[$seg]
} else {
# If it's any other string, return it as-is
put $seg
}
} elif (or (eq $k 'styled') (eq $k 'styled-text')) {
# If it's a styled object, return it as-is
put $seg
} else {
fail "Invalid segment of type "(kind-of $seg)": "(to-string $seg)". Must be fn, string or styled."
}
}
Given a list of segments (which can be built-in segment names, lambdas, strings or styled
objects), return the appropriate chain, including the chain connectors.
fn -build-chain {|segments|
if (eq $segments []) {
return
}
for seg $segments {
if (str:has-prefix (to-string $seg) "git-") {
-parse-git
break
}
}
var first = $true
var output = ""
for seg $segments {
set output = [(-interpret-segment $seg)]
if (> (count $output) 0) {
if (not $first) {
if (or $show-last-chain (not-eq $seg $segments[-1])) {
-colorized-glyph chain
}
}
put $@output
set first = $false
}
}
}
Finally, we get to the functions that build the left and right prompts, respectively. These are basically wrappers around -build-chain
with the corresponding arguments.
fn prompt {
if (not-eq $prompt-segments []) {
-build-chain $prompt-segments
}
}
fn rprompt {
if (not-eq $rprompt-segments []) {
-build-chain $rprompt-segments
}
}
Default setup function, assigning our functions to edit:prompt
and edit:rprompt
fn init {
set edit:prompt = $prompt~
set edit:rprompt = $rprompt~
}
NEW: Note that we do not call the init
function automatically on module load. This has changed in the latest version, so that the module can be loaded without setting up the prompt right away (e.g. to use the chain segments for other purposes).
chain:summary-status
provides a summarized list of the git-combined
and git-branch
indicators for a given set of repositories (I use this to check the status of repos on which I’m frequently working). The repositories to display can be provided in mutliple ways (if more than one is specified, the first one found is used):
- As arguments to the function, e.g.
chain:summary-status dir1 dir2
. - All the git repos inside your home directory:
chain:summary-status &all
. Note: this uses thefd
command by default, can be changed by storing the new function in$chain:find-all-user-repos
. Default value:var find-all-user-repos = { fd -H -I -t d '^.git$' ~ | each $path:dir~ }
- Read from a JSON file specified in
$chain:summary-repos-file
. Default value:var summary-repos-file = ~/.elvish/package-data/elvish-themes/chain-summary-repos.json
The contents of this file can be manipulated using the
chain:add-summary-repo
andchain:remove-summary-repo
.
The list of repositories read from the file is cached in $chain:summary-repos
.
var summary-repos = []
We define a couple of functions to read and write $chain:summary-repos
from disk.
fn -write-summary-repos {
mkdir -p (path:dir $summary-repos-file)
to-json [$summary-repos] > $summary-repos-file
}
fn -read-summary-repos {
try {
set summary-repos = (from-json < $summary-repos-file)
} catch {
set summary-repos = []
}
}
The chain:summary-data
function collects the data from a given set of repositories.
fn summary-data {|repos|
each {|r|
try {
cd $r
-parse-git &with-timestamp
var status = [($segment[git-combined])]
put [
&repo= (tilde-abbr $r)
&status= $status
&ts= $last-status[timestamp]
×tamp= ($segment[git-timestamp])
&branch= ($segment[git-branch])
]
} catch e {
put [
&repo= (tilde-abbr $r)
&status= [(styled '['(to-string $e)']' red)]
&ts= ""
×tamp= ""
&branch= ""
]
}
} $repos
}
The chain:summary-status
function is the main entry point to display the status of the configured repos.
fn summary-status {|@repos &all=$false &only-dirty=$false|
var prev = $pwd
# Determine how to sort the output. This only happens in newer
# versions of Elvish (where the order function exists)
use builtin
var order-cmd~ = $all~
if (has-key $builtin: order~) {
set order-cmd~ = { order &less-than={|a b| <s $a[ts] $b[ts] } &reverse }
}
# Read repo list from disk, cache in $chain:summary-repos
-read-summary-repos
# Determine the list of repos to display:
# 1) If the &all option is given, find them
if $all {
spinners:run &title="Finding all git repos" &style=blue {
set repos = [($find-all-user-repos)]
}
}
# 2) If repos is not given nor defined through &all, use $chain:summary-repos
if (eq $repos []) {
set repos = $summary-repos
}
# 3) If repos is specified, just use it
# Produce the output
spinners:run &title="Gathering repo data" &style=blue { summary-data $repos } | order-cmd | each {|r|
var status-display = $r[status]
if (or (not $only-dirty) (not-eq $status-display [])) {
if (eq $status-display []) {
set status-display = [(-colorized "[" session) (styled OK green) (-colorized "]" session)]
}
var @status = $r[timestamp] ' ' (all $status-display) ' ' $r[branch]
echo &sep="" $@status ' ' (-colorized $r[repo] (-segment-style git-repo))
}
}
cd $prev
}
The chain:add-summary-repo
and chain:remove-summary-repo
functions can be used to add/remove directories from the summary list. If no directories are given as arguments, they operate on the current directory.
fn add-summary-repo {|@dirs|
if (eq $dirs []) {
set dirs = [ $pwd ]
}
-read-summary-repos
each {|d|
if (has-value $summary-repos $d) {
echo (styled "Repo "$d" is already in the list" yellow)
} else {
set summary-repos = [ $@summary-repos $d ]
echo (styled "Repo "$d" added to the list" green)
}
} $dirs
-write-summary-repos
}
fn remove-summary-repo {|@dirs|
if (eq $dirs []) {
set dirs = [ $pwd ]
}
-read-summary-repos
var @new-repos = (each {|d|
if (not (has-value $dirs $d)) { put $d }
} $summary-repos)
each {|d|
if (has-value $summary-repos $d) {
echo (styled "Repo "$d" removed from the list." green)
} else {
echo (styled "Repo "$d" was not on the list" yellow)
}
} $dirs
set summary-repos = $new-repos
-write-summary-repos
}