f0d2d40f26a8
show what long actions are doing on the terminal: doctor names its check, build each platform, update its download, and offer, steal and a remote strip count what they move (3.2.11)
- time
- 2026-09-28 18:41 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- clear feedback
- parent
- 67dcb773afe1
↓ koh-f0d2d40f.tar.gzatlas at this save
files changed12 files
VERSION+1 −1src/cli/cmd_build.v+1 −0src/cli/cmd_doctor.v+1 −0src/cli/cmd_offer.v+5 −3src/cli/cmd_steal.v+9 −4src/cli/cmd_strip.v+1 −1src/cli/cmd_update.v+1 −0src/cli/output.v+5 −0src/cli/progress.v+100 −0src/cli/prompt.v+2 −0tests/cli_test.v+7 −1v.mod+1 −1src/cli/cmd_build.v+1 −0
@@ -36,6 +36,7 @@3636 platforms := wanted_platforms(args, config) or { return refuse(args.json(), err.msg()) }3737 mut built := []build.Built{}3838 for platform in platforms {39+ working('building ${platform}')3940 result := build.build_one(store.root, config, platform, published_version) or {4041 return refuse(args.json(), err.msg())4142 }src/cli/cmd_doctor.v+1 −0
@@ -28,6 +28,7 @@2828 problems: []string{}2929 }3030 31+ working('checking ${fmt.plural(int(store.object_count() or { 0 }), 'object')} and every save')3132 report.problems << store.integrity_check() or { return refuse(args.json(), err.msg()) }3233 report.problems << store.check_objects() or { return refuse(args.json(), err.msg()) }3334 report.problems << store.check_save_files() or { return refuse(args.json(), err.msg()) }src/cli/cmd_offer.v+5 −3
@@ -41,7 +41,9 @@4141 }4242 // Signed in since it was stolen: the bucket's saves come in first, by the steal a person4343 // would run, so a clash is asked about on a terminal and refused without one.44- mut bucket := this_machine_backend() or { return refuse(args.json(), err.msg()) }44+ mut bucket := watched_backend('bringing in your bucket') or {45+ return refuse(args.json(), err.msg())46+ }4547 mut options := exchange.StealOptions{}4648 if on_terminal(args.json()) {4749 options.guide = guide_steal@@ -63,7 +65,7 @@6365 warn_flagged(report.flagged)6466 }6567 }66- mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }68+ mut backend := watched_backend('offering') or { return refuse(args.json(), err.msg()) }6769 if args.flag('builds') {6870 return offer_builds(args, store, mut backend, flagged)6971 }@@ -157,7 +159,7 @@157159 if exchange.source_of(store) != '' {158160 return159161 }160- mut backend := this_machine_backend() or {162+ mut backend := watched_backend('offering') or {161163 if !quiet {162164 note('not offered: ${err.msg()}')163165 }src/cli/cmd_steal.v+9 −4
@@ -61,9 +61,11 @@6161 upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))6262 read_only := source != '' && !upgraded6363 mut backend := if read_only {64- exchange.read_only(source) or { return refuse(args.json(), err.msg()) }64+ remote.Backend(watch('stealing', exchange.read_only(source) or {65+ return refuse(args.json(), err.msg())66+ }))6567 } else {66- this_machine_backend() or { return refuse(args.json(), err.msg()) }68+ watched_backend('stealing') or { return refuse(args.json(), err.msg()) }6769 }6870 mut options := exchange.StealOptions{6971 mine: args.flag('mine')@@ -247,6 +249,7 @@247249 }248250 249251 fn guide_steal(preview exchange.StealPreview) !map[string]exchange.Side {252+ done_working()250253 _, columns := view.terminal_size()251254 width := if columns > 0 && columns < 80 { columns } else { 80 }252255 println('')@@ -414,14 +417,16 @@414417 if exchange.signed_in_to(base) {415418 return steal_repo(repo, args)416419 }417- mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }420+ mut backend := remote.Backend(watch('stealing ${repo}', exchange.read_only('${base}/${repo}') or {421+ return refuse(args.json(), err.msg())422+ }))418423 return steal_whole(repo, args, mut backend, '${base}/${repo}')419424 }420425 421426 // steal_repo fetches a whole repo into ./<name>, for a machine that does not422427 // have it yet.423428 fn steal_repo(name string, args Args) int {424- mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }429+ mut backend := watched_backend('stealing ${name}') or { return refuse(args.json(), err.msg()) }425430 return steal_whole(name, args, mut backend, '')426431 }427432 src/cli/cmd_strip.v+1 −1
@@ -121,7 +121,7 @@121121 skipped: true122122 }123123 }124- mut backend := this_machine_backend() or {124+ mut backend := watched_backend('updating the remote') or {125125 return SendOutcome{126126 failed: err.msg()127127 }src/cli/cmd_update.v+1 −0
@@ -69,6 +69,7 @@6969 if !approved {7070 return refuse(args.json(), 'not updating, then')7171 }72+ working('downloading koh ${latest.version}')7273 target := update.install(latest) or { return refuse(args.json(), err.msg()) }7374 if args.json() {7475 emit_json(json2.encode(UpdateJson{src/cli/output.v+5 −0
@@ -13,19 +13,23 @@1313 import src.fmt1414 1515 pub fn ok(message string) {16+ done_working()1617 println(' ${fmt.amber(fmt.glyph_ok)} ${message}')1718 }1819 1920 pub fn note(message string) {21+ done_working()2022 println(' ${fmt.dim(message)}')2123 }2224 2325 pub fn warn(message string) {26+ done_working()2427 println(' ${fmt.amber('!')} ${message}')2528 }2629 2730 // fail prints a refusal and returns the exit code to hand back.2831 pub fn fail(message string) int {32+ done_working()2933 eprintln(' ${fmt.red(fmt.glyph_fail)} ${message}')3034 return 13135 }@@ -57,5 +61,6 @@5761 }5862 5963 pub fn emit_json(value string) {64+ done_working()6065 println(value)6166 }src/cli/progress.v+100 −0
@@ -0,0 +1,100 @@1+//! The line a long action draws while it works (§4, "never leave the person2+//! wondering"): one line on stderr, redrawn in place and cleared when the step3+//! ends, and only when stderr is a terminal, so pipes, logs and `--json` never4+//! see it.5+6+module cli7+8+import os9+import src.fmt10+import src.remote11+import src.view12+13+fn working(text string) {14+ if os.is_atty(2) == 0 {15+ return16+ }17+ width := line_width()18+ shown := fmt.fit(' ${text}', width)19+ // Padded with spaces, not cleared with an escape code, so a plain terminal20+ // and NO_COLOR stay free of control sequences.21+ eprint('\r${fmt.dim(shown)}${' '.repeat(width - fmt.visible_width(shown))}')22+ flush_stderr()23+}24+25+fn done_working() {26+ if os.is_atty(2) != 0 {27+ eprint('\r${' '.repeat(line_width())}\r')28+ flush_stderr()29+ }30+}31+32+// line_width stays one column short of the terminal, so the line never wraps.33+fn line_width() int {34+ _, columns := view.terminal_size()35+ return if columns > 1 { columns - 1 } else { 79 }36+}37+38+// Watched counts what an offer, steal or strip moves through a backend, so the39+// line moves with the transfer. Exchange stays unaware of it.40+struct Watched {41+ label string42+mut:43+ inner remote.Backend44+ received int45+ sent int46+}47+48+// watched_backend is this machine's backend, counted on the line under `label`.49+fn watched_backend(label string) !remote.Backend {50+ return remote.Backend(watch(label, this_machine_backend()!))51+}52+53+fn watch(label string, backend remote.Backend) Watched {54+ return Watched{55+ label: label56+ inner: backend57+ }58+}59+60+fn (mut w Watched) show() {61+ mut text := w.label62+ if w.sent > 0 {63+ text += ' · ${w.sent} sent'64+ }65+ if w.received > 0 {66+ text += ' · ${w.received} received'67+ }68+ working(text)69+}70+71+fn (mut w Watched) get(key string) !remote.Object {72+ object := w.inner.get(key)!73+ w.received++74+ w.show()75+ return object76+}77+78+fn (mut w Watched) list(prefix string) ![]string {79+ return w.inner.list(prefix)!80+}81+82+fn (mut w Watched) put_if_absent(key string, body []u8) !bool {83+ put := w.inner.put_if_absent(key, body)!84+ w.sent++85+ w.show()86+ return put87+}88+89+fn (mut w Watched) put_if_match(key string, body []u8, tag string) !bool {90+ put := w.inner.put_if_match(key, body, tag)!91+ w.sent++92+ w.show()93+ return put94+}95+96+fn (mut w Watched) delete(key string) ! {97+ w.inner.delete(key)!98+ w.sent++99+ w.show()100+}src/cli/prompt.v+2 −0
@@ -20,6 +20,7 @@2020 if !interactive() {2121 return error('needs --yes when not interactive')2222 }23+ done_working()2324 print(' continue? [y/N] ')2425 answer := os.get_line()2526 println('')@@ -29,6 +30,7 @@2930 // require_true is confirm for a second, stricter question: the answer has to3031 // match `expected` exactly. `strip` uses it for the typed path.3132 pub fn typed_confirm(prompt_text string, expected string) bool {33+ done_working()3234 print(' ${prompt_text} ')3335 answer := os.get_line()3436 println('')tests/cli_test.v+7 −1
@@ -67,6 +67,12 @@6767 }6868 }6969 70+// shown is what a terminal leaves on screen: on each line, only what follows its71+// last carriage return, so a progress line cleared in place does not count.72+fn shown(out string) []string {73+ return out.split('\n').map(it.trim_right('\r').all_after_last('\r')).filter(it.trim_space() != '')74+}75+7076 fn run_pty_home(repo string, home string, answers []string, args ...string) Result {7177 stamp := '${os.getpid()}-${time.now().unix_nano()}'7278 transcript := os.join_path(os.temp_dir(), 'koh3-pty-${stamp}.txt')@@ -1184,7 +1190,7 @@11841190 assert run_home(a, home, 'save', 'fast forward').code == 011851191 forward := run_pty_home(b, home, []string{}, 'steal')11861192 assert forward.out.contains('proj is up to date · 1 new save')1187- assert forward.out.split_into_lines().filter(it.trim_space() != '').len == 11193+ assert shown(forward.out).len == 1, forward.out11881194 write(a, 'clash.txt', 'remote\n')11891195 write(a, 'other.txt', 'remote\n')11901196 write(a, 'same.txt', 'identical\n')v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.2.10'3+ version: '3.2.11'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }