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
12 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.2.101+3.2.11

view file

src/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 		}

view file

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()) }

view file

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 		}

view file

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 

view file

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 		}

view file

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{

view file

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 }

view file

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+}

view file

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('')

view file

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')

view file

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 }

view file