e6ac76b8f99a

add the Koh 3 zsh plugin, a prompt segment and completion built on status --json, which now reports saves, size_bytes and head_flagged (3.0.61)

time
2026-09-26 02:43 UTC · 2 weeks ago
machine
koh2
agent
claude-opus-5-5
session
koh voice
parent
bea6866dbe3e
5 files VERSION+1 −1shell/koh3.plugin.zsh+174 −0src/cli/cmd_status.v+11 −0tests/cli_test.v+9 −1v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.601+3.0.61

view file

shell/koh3.plugin.zsh+174 −0
@@ -0,0 +1,174 @@1+# koh3.plugin.zsh — Koh 3 in your zsh prompt, plus tab completion.2+#3+#   kiln · v2.1.0 · 6 saves · 4.2 MB !4+#   →5+#6+# The segment reads `koh status --json`, never the .koh file itself, so it7+# works with any schema and needs no sqlite3. `!` means unsaved changes and8+# `⚑` a flagged head save. Koh 2 repos (a .koh folder) are left to Koh 2's9+# plugin.10+#11+# Install: source this file from ~/.zshrc. To keep your own prompt, set12+# KOH_DEFAULT_PROMPT=false first and put $(koh_prompt) where you want it.13+# koh_prompt_info gives the same text without colour, for Starship or p10k:14+#15+#   [custom.koh]16+#   command = "koh_prompt_info"17+#   when = "test -f .koh"18+#   shell = ["zsh"]19+20+: ${KOH_COLOR_DIR:=73}21+: ${KOH_COLOR_VERSION:=214}22+: ${KOH_COLOR_META:=245}23+: ${KOH_COLOR_ALERT:=203}24+: ${KOH_SHOW_VERSION:=true}25+: ${KOH_SHOW_SAVES:=true}26+: ${KOH_SHOW_SIZE:=true}27+: ${KOH_PROMPT_CHAR:=→}28+: ${KOH_DEFAULT_PROMPT:=true}29+30+# During the transition the Koh 3 binary is koh3; after the cut-over it is koh.31+_koh_bin() {32+  if [[ -n $KOH_BIN ]]; then print -r -- $KOH_BIN33+  elif (( $+commands[koh3] )); then print koh334+  else print koh35+  fi36+}37+38+# The folder holding a Koh 3 .koh file, searching upward like koh does.39+_koh_root() {40+  local dir=$PWD41+  while true; do42+    [[ -f $dir/.koh ]] && { print -r -- $dir; return 0; }43+    [[ -d $dir/.koh || $dir == / ]] && return 144+    dir=${dir:h}45+  done46+}47+48+# The project's own version, from whichever manifest it keeps.49+_koh_version() {50+  local root=$1 line51+  if [[ -f $root/VERSION ]]; then52+    read -r line <$root/VERSION && print -r -- ${line// /}53+  elif [[ -f $root/v.mod ]]; then54+    [[ "$(<$root/v.mod)" =~ "version: *'([^']+)'" ]] && print -r -- $match[1]55+  elif [[ -f $root/package.json ]]; then56+    [[ "$(<$root/package.json)" =~ '"version" *: *"([^"]+)"' ]] && print -r -- $match[1]57+  elif [[ -f $root/Cargo.toml ]]; then58+    [[ "$(<$root/Cargo.toml)" =~ $'(^|\n)version *= *"([^"]+)"' ]] && print -r -- $match[2]59+  elif [[ -f $root/build.zig.zon ]]; then60+    [[ "$(<$root/build.zig.zon)" =~ '\.version *= *"([^"]+)"' ]] && print -r -- $match[1]61+  elif [[ -f $root/pyproject.toml ]]; then62+    [[ "$(<$root/pyproject.toml)" =~ $'(^|\n)version *= *"([^"]+)"' ]] && print -r -- $match[2]63+  fi64+}65+66+_koh_size() {67+  local -F value=$168+  local unit=B69+  for next in KB MB GB; do70+    (( value < 1024 )) && break71+    (( value /= 1024 ))72+    unit=$next73+  done74+  [[ $unit == B ]] && print -r -- "$1 B" || printf '%.1f %s' $value $unit75+}76+77+# _koh_segment <colour?> — the segment text, or nothing outside a Koh 3 repo.78+_koh_segment() {79+  local colour=$1 root json80+  root=$(_koh_root) || return81+  json=$(cd $root && command $(_koh_bin) status --json 2>/dev/null) || return82+  local saves size dirty flagged version83+  [[ $json =~ '"saves":([0-9]+)' ]] && saves=$match[1]84+  [[ $json =~ '"size_bytes":([0-9]+)' ]] && size=$(_koh_size $match[1])85+  [[ $json == *'"dirty":true'* ]] && dirty=186+  [[ $json == *'"head_flagged":true'* ]] && flagged=187+  [[ $KOH_SHOW_VERSION == true ]] && version=$(_koh_version $root)88+89+  local c_dir= c_version= c_meta= c_alert= reset=90+  if [[ -n $colour ]]; then91+    c_dir="%F{$KOH_COLOR_DIR}" c_version="%F{$KOH_COLOR_VERSION}"92+    c_meta="%F{$KOH_COLOR_META}" c_alert="%F{$KOH_COLOR_ALERT}" reset=%f93+  fi94+  local out="${c_dir}${root:t}${reset}"95+  [[ -n $version ]] && out+="${c_meta} · ${reset}${c_version}v${version#v}${reset}"96+  [[ $KOH_SHOW_SAVES == true && -n $saves ]] && out+="${c_meta} · $saves save$( (( saves == 1 )) || print s)${reset}"97+  [[ $KOH_SHOW_SIZE == true && -n $size ]] && out+="${c_meta} · $size${reset}"98+  [[ -n $dirty ]] && out+=" ${c_alert}!${reset}"99+  [[ -n $flagged ]] && out+=" ${c_alert}⚑${reset}"100+  print -rn -- $out101+}102+103+koh_prompt() { _koh_segment colour; }104+koh_prompt_info() { _koh_segment; }105+106+if [[ $KOH_DEFAULT_PROMPT == true ]]; then107+  setopt prompt_subst108+  _koh_precmd() {109+    local last=$?110+    local segment=$(koh_prompt)111+    if [[ -z $segment ]]; then112+      segment="%F{$KOH_COLOR_DIR}%2~%f"113+    fi114+    local char="%F{$KOH_COLOR_META}${KOH_PROMPT_CHAR}%f"115+    (( last != 0 )) && char="%F{$KOH_COLOR_ALERT}${KOH_PROMPT_CHAR}%f"116+    PROMPT="${segment}"$'\n'"${char} "117+  }118+  autoload -Uz add-zsh-hook119+  add-zsh-hook precmd _koh_precmd120+fi121+122+# Tab completion: the commands of `koh`'s usage, and save IDs where one is taken.123+_koh3() {124+  local -a commands=(125+    'save:snapshot the working tree'126+    'checkpoint:save, even with no changes'127+    'status:what changed since the last save'128+    'log:the line, newest first'129+    'diff:unified diff'130+    'undo:step back n saves'131+    'revert:discard unsaved changes'132+    'load:restore any save'133+    'remote:choose S3 or a folder, once per machine'134+    'offer:send new saves to the remote'135+    'steal:bring in saves from the remote'136+    'flag:mark a save unsafe'137+    'pluck:apply one save, or its inverse'138+    'peek:restore a save into a temp folder'139+    'init:create .koh here'140+    'doctor:integrity check and cleanup'141+    'strip:remove a path from history'142+    'migrate:convert a Koh 2 repo here'143+    'man:the manual, built in'144+    'saves:the history view on the saves tab'145+    'checkpoints:the history view on the checkpoints tab'146+  )147+  _koh3_ids() {148+    local -a ids149+    ids=(${(f)"$(command $(_koh_bin) log --json 2>/dev/null |150+      grep -o '"id":"[0-9a-f]*","parent":"[0-9a-f]*","message":"[^"]*"' |151+      sed -E 's/"id":"([0-9a-f]*)".*"message":"([^"]*)"/\1:\2/' | head -30)"})152+    _describe 'save' ids153+  }154+  if (( CURRENT == 2 )); then155+    _describe 'koh command' commands156+    return157+  fi158+  case $words[2] in159+    save|checkpoint) _arguments '--agent[model name]:agent:' '--session[session name]:session:' '--json' ;;160+    log)             _arguments '--since[filter by time]:expr:' '--session[session name]:session:' '--json' ;;161+    diff|load|peek)  _koh3_ids ;;162+    pluck)           _arguments '--reverse' '--dry-run' '*:save:_koh3_ids' ;;163+    flag)            _arguments '--reason[why]:reason:' '--clear' '--json' '*:save:_koh3_ids' ;;164+    undo)            _arguments '--session' '--yes' ;;165+    revert)          _arguments '--yes' ;;166+    remote)          _arguments '1:kind:(folder s3)' '--forget' '--json' ;;167+    steal)           _arguments '--list' '--mine' '--theirs' '--json' ;;168+    strip)           _arguments '--save[one save]:save:_koh3_ids' '*:path:_files' ;;169+    doctor)          _arguments '--backup[copy .koh to]:path:_files' ;;170+    migrate)         _arguments '--lane[Koh 2 lane]:lane:(dev main)' ;;171+    man)             _describe 'command' commands ;;172+  esac173+}174+(( $+functions[compdef] )) && compdef _koh3 ${$(_koh_bin):t}

view file

src/cli/cmd_status.v+11 −0
@@ -3,6 +3,8 @@33 module cli44 55 import json26+import os7+import src.events68 import src.fmt79 import src.history810 import src.snapshot@@ -31,6 +33,9 @@3133 	}3234 3335 	if args.json() {36+		line := history.line(store) or { return refuse(true, err.msg()) }37+		flags := events.latest_flags(store) or { return refuse(true, err.msg()) }38+		head_flag := flags[head.id] or { events.Flag{} }3439 		emit_json(json2.encode(StatusJson{3540 			head_id:            head.id3641 			head_msg:           head.message@@ -40,6 +45,9 @@4045 			deleted:            changes.removed4146 			ignore_changed:     changes.ignore_changed4247 			restore_unfinished: unfinished48+			saves:              line.len49+			size_bytes:         os.file_size(os.join_path(store.root, storage.file_name))50+			head_flagged:       head_flag.flagged4351 		}))4452 		return 04553 	}@@ -87,6 +95,9 @@8795 	deleted            []string8896 	ignore_changed     bool8997 	restore_unfinished bool98+	saves              int99+	size_bytes         u64100+	head_flagged       bool90101 }91102 92103 pub fn compare_with_head(store &storage.Store, head history.Save) !Changes {

view file

tests/cli_test.v+9 −1
@@ -149,10 +149,18 @@149149 	assert status.out.contains('added')150150 	js := run(dir, 'status', '--json')151151 	assert js.code == 0152-	for key in ['head_id', 'head_msg', 'dirty', 'modified', 'added', 'deleted', 'ignore_changed'] {152+	for key in ['head_id', 'head_msg', 'dirty', 'modified', 'added', 'deleted', 'ignore_changed',153+		'saves', 'size_bytes', 'head_flagged'] {153154 		read_json_has(js.out, key)154155 	}155156 	assert js.out.contains('"ignore_changed":true')157+	// What the shell prompt shows: saves on the line, the .koh size, the flag.158+	assert js.out.contains('"saves":2')159+	assert js.out.contains('"size_bytes":${os.file_size(os.join_path(dir, '.koh'))}')160+	assert js.out.contains('"head_flagged":false')161+	head := js.out.all_after('"head_id":"').all_before('"')162+	assert run(dir, 'flag', head, '--reason', 'bad').code == 0163+	assert run(dir, 'status', '--json').out.contains('"head_flagged":true')156164 }157165 158166 fn test_status_requires_repo_and_ignore_rules() {

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.60'3+	version: '3.0.61'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file