942d08c78d27

add phase 2: history view, flag, pluck, peek, doctor and local strip

time
2026-09-23 20:04 UTC · 2 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
koh 3 implementation
parent
525088cda79c
26 files src/apply/apply.v+129 −0src/apply/apply_test.v+127 −0src/cli/cli.v+30 −1src/cli/cmd_back.v+30 −10src/cli/cmd_diff.v+2 −1src/cli/cmd_doctor.v+125 −0src/cli/cmd_flag.v+102 −0src/cli/cmd_log.v+110 −27src/cli/cmd_peek.v+63 −0src/cli/cmd_pluck.v+135 −0src/cli/cmd_save.v+12 −9src/cli/cmd_strip.v+162 −0src/cli/format.v+0 −41src/cli/output.v+16 −28src/events/events.v+240 −0src/events/events_test.v+76 −0src/fmt/fmt.v+111 −0src/history/commit.v+3 −4src/history/log_md.v+4 −2src/view/term.v+183 −0src/view/term_test.v+29 −0src/view/view.v+619 −0src/view/view_test.v+149 −0tests/cli_test.v+243 −0vendor/shim/koh_term.c+102 −0vendor/shim/koh_term.h+25 −0
src/apply/apply.v+129 −0
@@ -0,0 +1,129 @@1+//! The per-file three-way engine, shared by `pluck` and by combine's replay.2+//!3+//! One save's change is applied onto the current tree file by file. For each4+//! path in scope the current content must match either the base (clean apply,5+//! take the new content) or the target (already applied, leave it); anything6+//! else refuses rather than guessing. There is no line-level merging: the unit7+//! of resolution is the file, which is why a clash is always a hard conflict.8+9+module apply10+11+import src.snapshot12+13+pub struct Plan {14+pub mut:15+	files    []snapshot.FileEntry // the tree that results from applying16+	applied  []string             // paths the plan changes17+	conflict string               // first path that does not apply cleanly18+}19+20+pub fn conflicts(plan Plan) bool {21+	return plan.conflict != ''22+}23+24+// three_way applies the change from `base` to `target` onto `current`.25+// `filters` limits it to those paths or folders; an empty list means everything.26+pub fn three_way(current []snapshot.FileEntry, base []snapshot.FileEntry, target []snapshot.FileEntry, filters []string) Plan {27+	base_map := snapshot.index_files(base)28+	target_map := snapshot.index_files(target)29+	current_map := snapshot.index_files(current)30+31+	mut paths := []string{}32+	for path in base_map.keys() {33+		if in_scope(path, filters) {34+			paths << path35+		}36+	}37+	for path in target_map.keys() {38+		if in_scope(path, filters) && path !in base_map {39+			paths << path40+		}41+	}42+	paths.sort()43+44+	mut plan := Plan{45+		files:   current.clone()46+		applied: []string{}47+	}48+	for path in paths {49+		from := base_map[path] or {50+			snapshot.FileEntry{51+				path: path52+			}53+		}54+		to := target_map[path] or {55+			snapshot.FileEntry{56+				path: path57+			}58+		}59+		if same_entry(from, to) {60+			continue61+		}62+		here := current_map[path] or {63+			snapshot.FileEntry{64+				path: path65+			}66+		}67+		if same_entry(here, from) {68+			plan.files = set_entry(plan.files, path, to, path in target_map)69+			plan.applied << path70+			continue71+		}72+		if same_entry(here, to) {73+			// Somebody already applied this change; nothing to do.74+			continue75+		}76+		plan.conflict = path77+		return plan78+	}79+	return plan80+}81+82+pub fn in_scope(path string, filters []string) bool {83+	if filters.len == 0 {84+		return true85+	}86+	for raw in filters {87+		filter := raw.trim_right('/')88+		if path == filter || path.starts_with('${filter}/') {89+			return true90+		}91+	}92+	return false93+}94+95+// same_entry treats two absent paths as equal, and compares presence, content96+// and the executable bit otherwise.97+fn same_entry(a snapshot.FileEntry, b snapshot.FileEntry) bool {98+	if a.hash == '' && b.hash == '' {99+		return true100+	}101+	return a.hash == b.hash && a.executable == b.executable102+}103+104+// set_entry replaces one path in a sorted tree, adding or removing it, and105+// keeps the list ordered by path.106+fn set_entry(files []snapshot.FileEntry, path string, entry snapshot.FileEntry, keep bool) []snapshot.FileEntry {107+	mut out := []snapshot.FileEntry{}108+	mut placed := false109+	for file in files {110+		if file.path == path {111+			if keep {112+				out << entry113+			}114+			placed = true115+			continue116+		}117+		if !placed && file.path > path {118+			if keep {119+				out << entry120+			}121+			placed = true122+		}123+		out << file124+	}125+	if !placed && keep {126+		out << entry127+	}128+	return out129+}

view file

src/apply/apply_test.v+127 −0
@@ -0,0 +1,127 @@1+module apply2+3+import src.snapshot4+5+fn entry(path string, hash string) snapshot.FileEntry {6+	return snapshot.FileEntry{7+		path: path8+		hash: hash9+	}10+}11+12+fn hashes(files []snapshot.FileEntry) map[string]string {13+	mut out := map[string]string{}14+	for file in files {15+		out[file.path] = file.hash16+	}17+	return out18+}19+20+fn test_clean_apply_takes_the_new_content() {21+	current := [entry('a.txt', 'aaa'), entry('b.txt', 'bbb')]22+	base := [entry('a.txt', 'aaa'), entry('b.txt', 'bbb')]23+	target := [entry('a.txt', 'AAA'), entry('b.txt', 'bbb')]24+25+	plan := three_way(current, base, target, [])26+	assert !conflicts(plan)27+	assert plan.applied == ['a.txt']28+	assert hashes(plan.files) == {29+		'a.txt': 'AAA'30+		'b.txt': 'bbb'31+	}32+}33+34+fn test_already_applied_is_left_alone() {35+	current := [entry('a.txt', 'AAA')]36+	base := [entry('a.txt', 'aaa')]37+	target := [entry('a.txt', 'AAA')]38+39+	plan := three_way(current, base, target, [])40+	assert !conflicts(plan)41+	assert plan.applied.len == 042+	assert hashes(plan.files) == {43+		'a.txt': 'AAA'44+	}45+}46+47+fn test_a_file_the_user_has_since_edited_is_a_hard_conflict() {48+	current := [entry('a.txt', 'mine')]49+	base := [entry('a.txt', 'aaa')]50+	target := [entry('a.txt', 'theirs')]51+52+	plan := three_way(current, base, target, [])53+	assert conflicts(plan)54+	assert plan.conflict == 'a.txt'55+}56+57+fn test_additions_removals_and_untouched_paths() {58+	current := [entry('kept.txt', 'keep'), entry('gone.txt', 'bye')]59+	base := [entry('gone.txt', 'bye')]60+	target := [entry('added.txt', 'new')]61+62+	plan := three_way(current, base, target, [])63+	assert !conflicts(plan)64+	assert plan.applied == ['added.txt', 'gone.txt']65+	assert hashes(plan.files) == {66+		'added.txt': 'new'67+		'kept.txt':  'keep'68+	}69+}70+71+fn test_filters_limit_the_change_to_paths_or_folders() {72+	current := [entry('src/a.txt', 'aaa'), entry('docs/b.txt', 'bbb')]73+	base := [entry('src/a.txt', 'aaa'), entry('docs/b.txt', 'bbb')]74+	target := [entry('src/a.txt', 'AAA'), entry('docs/b.txt', 'BBB')]75+76+	plan := three_way(current, base, target, ['src'])77+	assert plan.applied == ['src/a.txt']78+	assert hashes(plan.files) == {79+		'src/a.txt':  'AAA'80+		'docs/b.txt': 'bbb'81+	}82+83+	// A folder filter also covers everything beneath it, with or without the84+	// trailing slash.85+	plan2 := three_way(current, base, target, ['docs/'])86+	assert plan2.applied == ['docs/b.txt']87+}88+89+fn test_reverse_is_the_same_engine_with_the_sides_swapped() {90+	// pluck --reverse passes base=target and target=base; the engine does not91+	// need to know which way round it is.92+	current := [entry('a.txt', 'AAA')]93+	original := [entry('a.txt', 'aaa')]94+	changed := [entry('a.txt', 'AAA')]95+96+	forward := three_way(current, original, changed, [])97+	assert forward.applied.len == 098+99+	back := three_way(current, changed, original, [])100+	assert !conflicts(back)101+	assert hashes(back.files) == {102+		'a.txt': 'aaa'103+	}104+}105+106+fn test_executable_bit_counts_as_a_change() {107+	current := [snapshot.FileEntry{108+		path: 'run.sh'109+		hash: 'same'110+	}]111+	base := [snapshot.FileEntry{112+		path: 'run.sh'113+		hash: 'same'114+	}]115+	target := [116+		snapshot.FileEntry{117+			path:       'run.sh'118+			hash:       'same'119+			executable: true120+		},121+	]122+123+	plan := three_way(current, base, target, [])124+	assert !conflicts(plan)125+	assert plan.applied == ['run.sh']126+	assert plan.files[0].executable127+}

view file

src/cli/cli.v+30 −1
@@ -24,9 +24,17 @@2424   revert                                         discard unsaved changes2525   load <id> | --checkpoint                       restore any save2626 27-setup27+occasional28+  flag <id> [--reason "..."] | --clear <id>      mark a save unsafe29+  pluck <id> [--reverse] [--dry-run] [path...]   apply one save, or its inverse30+  peek <id>                                      restore a save into a temp folder31+32+setup and care2833   init                                           create .koh here34+  doctor [--backup <path>]                       integrity check and cleanup35+  strip <path> [--save <id>]                     remove a path from history2936 37+  saves / checkpoints open the history view on that tab3038   --json on any command that prints data, --yes to skip a confirm prompt'3139 3240 pub fn run(argv []string) int {@@ -59,6 +67,12 @@5967 		'log' {6068 			return cmd_log(rest)6169 		}70+		'saves' {71+			return cmd_saves(rest)72+		}73+		'checkpoints' {74+			return cmd_checkpoints(rest)75+		}6276 		'diff' {6377 			return cmd_diff(rest)6478 		}@@ -70,6 +84,21 @@7084 		}7185 		'load' {7286 			return cmd_load(rest)87+		}88+		'flag' {89+			return cmd_flag(rest)90+		}91+		'pluck' {92+			return cmd_pluck(rest)93+		}94+		'peek' {95+			return cmd_peek(rest)96+		}97+		'doctor' {98+			return cmd_doctor(rest)99+		}100+		'strip' {101+			return cmd_strip(rest)73102 		}74103 		else {75104 			eprintln('  koh: unknown command "${argv[1]}"')

view file

src/cli/cmd_back.v+30 −10
@@ -11,6 +11,7 @@1111 module cli1212 1313 import json14+import src.fmt1415 import src.history1516 import src.snapshot1617 import src.storage@@ -112,22 +113,16 @@112113 		for delta in plan.deltas {113114 			println('  ${change_mark(delta.kind)} ${delta.path}')114115 		}115-		note('${plural(plan.deltas.len, 'file')} change  ·  ${plural(plan.remove.len, 'file')} removed')116+		note('${fmt.plural(plan.deltas.len, 'file')} change  ·  ${fmt.plural(plan.remove.len,117+			'file')} removed')116118 	}117119 	approved := confirm(args.flag('yes')) or { return refuse(as_json, err.msg()) }118120 	if !approved {119121 		return 1120122 	}121-	if discard_first {122-		history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{123-			message:       'revert: discarded changes'124-			is_checkpoint: true125-		}) or { return refuse(as_json, err.msg()) }123+	save := apply_restore(store, target, verb, plan, discard_first) or {124+		return refuse(as_json, err.msg())126125 	}127-	save := history.commit_files(store, '', plan.files, history.SaveInput{128-		message:  '${verb}: ${target.id}'129-		restores: target.id130-	}) or { return refuse(as_json, err.msg()) }131126 	report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files,132127 		plan.remove) or { return refuse(as_json, err.msg()) }133128 	if as_json {@@ -142,6 +137,31 @@142137 	}143138 	ok('restored [${save.id}]  ${save.message}')144139 	return 0140+}141+142+// apply_restore records the going-back as a new save. It does not touch the143+// working tree and does not ask anything: the history view has already asked,144+// and the order still has to be save-first, files-second.145+pub fn apply_restore(store &storage.Store, target history.Save, verb string, plan RestorePlan, discard_first bool) !history.Save {146+	if discard_first {147+		history.commit_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{148+			message:       'revert: discarded changes'149+			is_checkpoint: true150+		})!151+	}152+	return history.commit_files(store, '', plan.files, history.SaveInput{153+		message:  '${verb}: ${target.id}'154+		restores: target.id155+	})!156+}157+158+// restore_now performs a whole restore without prompting: the plan, the save159+// and the files. The history view uses it for its `l` key.160+pub fn restore_now(store &storage.Store, target history.Save, verb string) !history.Save {161+	plan := plan_restore(store, target, false)!162+	save := apply_restore(store, target, verb, plan, false)!163+	snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files, plan.remove)!164+	return save145165 }146166 147167 struct RestorePlan {

view file

src/cli/cmd_diff.v+2 −1
@@ -4,6 +4,7 @@44 module cli55 66 import json7+import src.fmt78 import src.history89 import src.snapshot910 import src.storage@@ -45,7 +46,7 @@4546 		added += delta.added4647 		removed += delta.removed4748 	}48-	note('${plural(deltas.len, 'file')} changed  ·  ${green('+' + added.str())} ${red('-' +49+	note('${fmt.plural(deltas.len, 'file')} changed  ·  ${green('+' + added.str())} ${red('-' +4950 		removed.str())}')5051 	return 05152 }

view file

src/cli/cmd_doctor.v+125 −0
@@ -0,0 +1,125 @@1+//! `koh doctor` — integrity check and cleanup.2+//!3+//! One source of truth means there is nothing to rebuild; what is left to check4+//! is that the file is sound and that every object a save names is really5+//! there. Doctor also tidies the things that are safe to lose: the file cache6+//! and old peek folders.7+8+module cli9+10+import json11+import os12+import src.history13+import src.storage14+15+pub fn cmd_doctor(raw []string) int {16+	args := parse('doctor', raw, Options{17+		values: ['backup']18+		flags:  ['json']19+	}) or { return usage_error(err.msg()) }20+	store := open_store() or { return refuse(args.json(), err.msg()) }21+	defer {22+		store.close()23+	}24+	mut report := DoctorJson{25+		problems: []string{}26+	}27+28+	report.problems << store.integrity_check() or { return refuse(args.json(), err.msg()) }29+	report.problems << check_objects(store) or { return refuse(args.json(), err.msg()) }30+	report.problems << check_save_files(store) or { return refuse(args.json(), err.msg()) }31+32+	report.objects = int(store.object_count() or { 0 })33+	report.saves = int(history.save_count(store) or { 0 })34+	report.checkpoints = int(history.checkpoint_count(store) or { 0 })35+	report.cached_files = int(store.cache_count() or { 0 })36+	report.peek_folders_removed = sweep_peek_folders()37+	store.drop_cache() or { return refuse(args.json(), err.msg()) }38+39+	if path := args.value_or_none('backup') {40+		if os.exists(path) {41+			return refuse(args.json(), '${path} already exists')42+		}43+		store.backup(path) or { return refuse(args.json(), err.msg()) }44+		report.backup = path45+	}46+47+	if args.json() {48+		emit_json(json.encode(report))49+	} else {50+		if report.problems.len == 0 {51+			ok('${store.root} is sound')52+		} else {53+			for problem in report.problems {54+				warn(problem)55+			}56+		}57+		note('${report.objects} objects · ${report.saves} saves · ${report.checkpoints} checkpoints')58+		if report.backup != '' {59+			ok('backed up to ${report.backup}')60+		}61+	}62+	if report.problems.len > 0 {63+		return 164+	}65+	return 066+}67+68+// check_objects reads every object and hashes it back. Nothing else proves the69+// store is intact; the hash is the point of the store.70+fn check_objects(store &storage.Store) ![]string {71+	mut problems := []string{}72+	mut stmt := store.db.prepare('SELECT hash FROM objects ORDER BY hash')!73+	defer {74+		stmt.finalize()75+	}76+	mut hashes := []string{}77+	for stmt.step()! {78+		hashes << stmt.text(0)79+	}80+	for hash in hashes {81+		store.get_object(hash) or { problems << 'object ${hash}: ${err.msg()}' }82+	}83+	return problems84+}85+86+// check_save_files catches a save naming an object that is gone — the one87+// inconsistency the schema cannot prevent on its own, because objects are88+// shared between saves.89+fn check_save_files(store &storage.Store) ![]string {90+	mut problems := []string{}91+	mut stmt := store.db.prepare('SELECT DISTINCT f.save_id, f.hash FROM save_files f92+		LEFT JOIN objects o ON o.hash = f.hash WHERE o.hash IS NULL')!93+	defer {94+		stmt.finalize()95+	}96+	for stmt.step()! {97+		problems << 'save ${stmt.text(0)} needs missing object ${stmt.text(1)}'98+	}99+	return problems100+}101+102+// sweep_peek_folders clears the scratch trees `koh peek` leaves in the system103+// temp directory.104+fn sweep_peek_folders() int {105+	mut removed := 0106+	for name in os.ls(os.temp_dir()) or { return 0 } {107+		if !name.starts_with(peek_prefix) {108+			continue109+		}110+		os.rmdir_all(os.join_path(os.temp_dir(), name)) or { continue }111+		removed++112+	}113+	return removed114+}115+116+struct DoctorJson {117+mut:118+	problems             []string119+	objects              int120+	saves                int121+	checkpoints          int122+	cached_files         int123+	peek_folders_removed int124+	backup               string125+}

view file

src/cli/cmd_flag.v+102 −0
@@ -0,0 +1,102 @@1+//! `koh flag` — mark a save unsafe, clear the mark, or list what is marked.2+//!3+//! A flag is an event, not a column: save records are write-once, and clearing4+//! a flag is itself an event, so flagging stays undoable and syncs like5+//! everything else.6+7+module cli8+9+import json10+import src.fmt11+import src.events12+import src.history13+import src.storage14+15+pub fn cmd_flag(raw []string) int {16+	args := parse('flag', raw, Options{17+		values: ['reason']18+		flags:  ['clear', 'json']19+	}) or { return usage_error(err.msg()) }20+	store := open_store() or { return refuse(args.json(), err.msg()) }21+	defer {22+		store.close()23+	}24+	if args.positional.len == 0 && !args.flag('clear') {25+		return list_flags(store, args)26+	}27+	id := args.positional_at(0)28+	if id == '' {29+		return usage_error('flag needs a save ID, or nothing at all to list flagged saves')30+	}31+	save := history.resolve(store, id) or { return refuse(args.json(), err.msg()) }32+	if args.flag('clear') {33+		events.clear_flag(store, save.id) or { return refuse(args.json(), err.msg()) }34+		if args.json() {35+			emit_json(json.encode(FlagJson{36+				save_id: save.id37+				flagged: false38+				reason:  ''39+			}))40+			return 041+		}42+		ok('cleared the flag on [${save.id}]')43+		return 044+	}45+	reason := args.value('reason')46+	events.flag(store, save.id, reason) or { return refuse(args.json(), err.msg()) }47+	if args.json() {48+		emit_json(json.encode(FlagJson{49+			save_id: save.id50+			flagged: true51+			reason:  reason52+		}))53+		return 054+	}55+	ok('flagged [${save.id}]${if reason != '' { ' — ${reason}' } else { '' }}')56+	return 057+}58+59+fn list_flags(store &storage.Store, args Args) int {60+	flags := events.latest_flags(store) or { return refuse(args.json(), err.msg()) }61+	mut flagged := []FlagJson{}62+	for save_id, state in flags {63+		if !state.flagged {64+			continue65+		}66+		flagged << FlagJson{67+			save_id: save_id68+			flagged: true69+			reason:  state.reason70+		}71+	}72+	flagged.sort(a.save_id < b.save_id)73+	if args.json() {74+		emit_json(json.encode(FlagListJson{75+			flagged: flagged76+		}))77+		return 078+	}79+	if flagged.len == 0 {80+		ok('nothing is flagged')81+		return 082+	}83+	for entry in flagged {84+		save := history.by_id(store, entry.save_id) or { continue }85+		mut line := '  ${amber('⚑')} ${dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)}  ${save.message}'86+		if entry.reason != '' {87+			line += '  ${dim(entry.reason)}'88+		}89+		println(line)90+	}91+	return 092+}93+94+struct FlagJson {95+	save_id string96+	flagged bool97+	reason  string98+}99+100+struct FlagListJson {101+	flagged []FlagJson102+}

view file

src/cli/cmd_log.v+110 −27
@@ -1,16 +1,40 @@1-//! `koh log` — the line, newest first.1+//! `koh log`, and its `saves` and `checkpoints` aliases.22 //!3-//! On a terminal this opens the history view; piped or with `--json` it prints4-//! plain text, so an agent always gets something it can parse.3+//! On a terminal these open the history view on their tab; piped, or with4+//! `--json`, they print plain text, so an agent always gets something it can5+//! parse.56 67 module cli78 89 import json10+import os911 import time12+import src.events13+import src.fmt1014 import src.history15+import src.storage16+import src.view1117 1218 pub fn cmd_log(raw []string) int {13-	args := parse('log', raw, Options{19+	return history_command(raw, .all)20+}21+22+pub fn cmd_saves(raw []string) int {23+	return history_command(raw, .saves)24+}25+26+pub fn cmd_checkpoints(raw []string) int {27+	return history_command(raw, .checkpoints)28+}29+30+fn history_command(raw []string, tab view.Tab) int {31+	verb := match tab {32+		.all { 'log' }33+		.saves { 'saves' }34+		else { 'checkpoints' }35+	}36+37+	args := parse(verb, raw, Options{1438 		values: ['since', 'session']1539 		flags:  ['json']1640 	}) or { return usage_error(err.msg()) }@@ -18,14 +42,20 @@1842 	defer {1943 		store.close()2044 	}45+	if !args.json() && on_a_terminal() {46+		return history_view(store, tab)47+	}2148 	mut cutoff := i64(0)2249 	if expr := args.value_or_none('since') {2350 		cutoff = parse_since(expr) or { return refuse(args.json(), err.msg()) }2451 	}2552 	session := args.value('session')2653 	head_id := history.head_id(store)54+	flags := events.latest_flags(store) or {55+		map[string]events.Flag{}56+	}2757 	mut shown := []history.Save{}28-	for save in history.line(store) or { return refuse(args.json(), err.msg()) } {58+	for save in line_for_tab(store, tab) or { return refuse(args.json(), err.msg()) } {2959 		if cutoff > 0 && save.created_at < cutoff {3060 			continue3161 		}@@ -46,22 +76,75 @@4676 		return 04777 	}4878 	for save in shown {49-		println(log_line(save, save.id == head_id))79+		println(log_line(save, save.id == head_id, flags))5080 	}5181 	return 05282 }5383 84+// line_for_tab picks the saves a tab shows. `log` and `saves` use the same85+// filter; checkpoints narrows the line to checkpoints.86+fn line_for_tab(store &storage.Store, tab view.Tab) ![]history.Save {87+	if tab != .checkpoints {88+		return history.line(store)89+	}90+	mut out := []history.Save{}91+	for save in history.line(store)! {92+		if save.is_checkpoint {93+			out << save94+		}95+	}96+	return out97+}98+99+fn history_view(store &storage.Store, tab view.Tab) int {100+	view.run(store, tab, history_view_action) or { return fail(err.msg()) }101+	return 0102+}103+104+// history_view_action is what the view calls when the user picks an action.105+// The view presents; this changes history.106+fn history_view_action(store &storage.Store, action view.Action) string {107+	match action.kind {108+		.load {109+			target := history.by_id(store, action.save_id) or {110+				return 'no such save: ${action.save_id}'111+			}112+			restore_now(store, target, 'load') or { return 'could not load: ${err.msg()}' }113+			return 'loaded [${action.save_id}]'114+		}115+		.flag {116+			events.flag(store, action.save_id, action.reason) or {117+				return 'could not flag: ${err.msg()}'118+			}119+			return 'flagged [${action.save_id}]'120+		}121+		.clear_flag {122+			events.clear_flag(store, action.save_id) or { return 'could not clear: ${err.msg()}' }123+			return 'cleared the flag on [${action.save_id}]'124+		}125+		.none {126+			return ''127+		}128+	}129+}130+131+fn on_a_terminal() bool {132+	return os.is_atty(0) != 0 && os.is_atty(1) != 0133+}134+54135 struct SaveJson {136+	// Every field is always present, so a consumer never has to tell "absent"137+	// from "missing key". An empty string means the save recorded none.55138 	id            string56-	parent        ?string139+	parent        string57140 	message       string58141 	timestamp     i6459142 	machine       string60-	agent         ?string61-	session       ?string143+	agent         string144+	session       string62145 	is_checkpoint bool63-	restores      ?string64-	replayed_from ?string146+	restores      string147+	replayed_from string65148 }66149 67150 struct LogJson {@@ -72,28 +155,21 @@72155 fn to_json(save history.Save) SaveJson {73156 	return SaveJson{74157 		id:            save.id75-		parent:        optional(save.parent)158+		parent:        save.parent76159 		message:       save.message77160 		timestamp:     save.created_at78161 		machine:       save.machine79-		agent:         optional(save.agent)80-		session:       optional(save.session)162+		agent:         save.agent163+		session:       save.session81164 		is_checkpoint: save.is_checkpoint82-		restores:      optional(save.restores)83-		replayed_from: optional(save.replayed_from)165+		restores:      save.restores166+		replayed_from: save.replayed_from84167 	}85168 }86169 87-fn optional(value string) ?string {88-	if value == '' {89-		return none90-	}91-	return value92-}93-94-fn log_line(save history.Save, is_head bool) string {95-	marker := if is_head { amber('▸') } else { ' ' }96-	mut line := '  ${marker} ${dim('[' + save.id + ']')} ${stamp(save.created_at)}  ${save.message}'170+fn log_line(save history.Save, is_head bool, flags map[string]events.Flag) string {171+	marker := if is_head { fmt.amber('▸') } else { ' ' }172+	mut line := '  ${marker} ${fmt.dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)}  ${save.message}'97173 	mut tags := []string{}98174 	if save.agent != '' || save.session != '' {99175 		mut inner := []string{}@@ -108,6 +184,13 @@108184 	if save.is_checkpoint {109185 		tags << '◐'110186 	}187+	if (flags[save.id] or {188+		events.Flag{189+			save_id: save.id190+		}191+	}).flagged {192+		tags << '⚑'193+	}111194 	if save.restores != '' {112195 		tags << '↩ ${save.restores}'113196 	}@@ -115,7 +198,7 @@115198 		tags << 'replayed'116199 	}117200 	if tags.len > 0 {118-		line += '  ${dim(tags.join('  '))}'201+		line += '  ${fmt.dim(tags.join('  '))}'119202 	}120203 	return line121204 }

view file

src/cli/cmd_peek.v+63 −0
@@ -0,0 +1,63 @@1+//! `koh peek` — put a save's tree in a temporary folder and print where.2+//!3+//! The working tree and history are untouched: a peek is a read of the object4+//! store into a scratch folder the user can look at, then throw away.5+6+module cli7+8+import json9+import os10+import src.fmt11+import src.history12+import src.snapshot13+14+pub fn cmd_peek(raw []string) int {15+	args := parse('peek', raw, Options{16+		flags: ['json']17+	}) or { return usage_error(err.msg()) }18+	store := open_store() or { return refuse(args.json(), err.msg()) }19+	defer {20+		store.close()21+	}22+	id := args.positional_at(0)23+	if id == '' {24+		return usage_error('peek needs a save ID')25+	}26+	save := history.resolve(store, id) or { return refuse(args.json(), err.msg()) }27+	files := history.files_of(store, save.id) or { return refuse(args.json(), err.msg()) }28+	path := peek_path(save.id)29+	// Re-peeking the same save replaces its folder rather than merging into it.30+	if os.exists(path) {31+		os.rmdir_all(path) or { return refuse(args.json(), err.msg()) }32+	}33+	os.mkdir_all(path, os.MkdirParams{}) or { return refuse(args.json(), err.msg()) }34+	snapshot.restore_files(store, path, snapshot.Ignore{}, files, []) or {35+		return refuse(args.json(), err.msg())36+	}37+	if args.json() {38+		emit_json(json.encode(PeekJson{39+			id:    save.id40+			path:  path41+			files: files.len42+		}))43+		return 044+	}45+	ok('peeked [${save.id}] into')46+	println(path)47+	note('${fmt.plural(files.len, 'file')} — delete the folder when you are done')48+	return 049+}50+51+// peek_path is where a save's scratch copy lives. `doctor` sweeps these folders52+// out of the temp directory.53+pub fn peek_path(save_id string) string {54+	return os.join_path(os.temp_dir(), '${peek_prefix}${save_id}')55+}56+57+pub const peek_prefix = 'koh-peek-'58+59+struct PeekJson {60+	id    string61+	path  string62+	files int63+}

view file

src/cli/cmd_pluck.v+135 −0
@@ -0,0 +1,135 @@1+//! `koh pluck` — put one save's change onto the current tree.2+//!3+//! The engine is per file, never per line: for every path in scope the current4+//! content must match either the save's parent (clean apply) or the save's own5+//! version (already applied). Anything else is a hard conflict, reported by6+//! name, and nothing is applied.7+8+module cli9+10+import json11+import src.fmt12+import src.apply13+import src.history14+import src.snapshot15+16+pub fn cmd_pluck(raw []string) int {17+	args := parse('pluck', raw, Options{18+		flags: ['reverse', 'dry-run', 'yes', 'json']19+	}) or { return usage_error(err.msg()) }20+	store := open_store() or { return refuse(args.json(), err.msg()) }21+	defer {22+		store.close()23+	}24+	id := args.positional_at(0)25+	if id == '' {26+		return usage_error('pluck needs a save ID')27+	}28+	source := history.resolve(store, id) or { return refuse(args.json(), err.msg()) }29+	mut filters := []string{}30+	if args.positional.len > 1 {31+		filters = args.positional[1..]32+	}33+	reverse := args.flag('reverse')34+	verb := if reverse { 'pluck reverse' } else { 'pluck' }35+36+	source_tree := tree_of(store, source.id) or { return refuse(args.json(), err.msg()) }37+	mut parent_tree := snapshot.Tree{}38+	if source.parent != '' {39+		parent_tree = tree_of(store, source.parent) or { return refuse(args.json(), err.msg()) }40+	}41+	current := working_tree(store) or { return refuse(args.json(), err.msg()) }42+	// Forward pluck applies the save's change; --reverse applies its inverse.43+	base := if reverse { source_tree } else { parent_tree }44+	target := if reverse { parent_tree } else { source_tree }45+46+	plan := apply.three_way(current.files, base.files, target.files, filters)47+	if apply.conflicts(plan) {48+		return refuse(args.json(),49+			'${plan.conflict} has changed since [${source.id}]; koh does not merge lines — resolve it by hand, or load the save and re-edit')50+	}51+52+	if args.flag('dry-run') || plan.applied.len == 0 {53+		if args.json() {54+			emit_json(json.encode(PluckJson{55+				source:     source.id56+				reverse:    reverse57+				applied:    plan.applied58+				applied_ok: true59+			}))60+			return 061+		}62+		if plan.applied.len == 0 {63+			ok('nothing to apply from [${source.id}]')64+			return 065+		}66+		ok('${verb} [${source.id}] would change:')67+		for path in plan.applied {68+			println('  ${amber('~')} ${path}')69+		}70+		note('${fmt.plural(plan.applied.len, 'file')} — run again without --dry-run to apply')71+		return 072+	}73+74+	ok('${verb}: [${source.id}]  ${source.message}')75+	for path in plan.applied {76+		println('  ${amber('~')} ${path}')77+	}78+	note('${fmt.plural(plan.applied.len, 'file')} change')79+	approved := confirm(args.flag('yes')) or { return refuse(args.json(), err.msg()) }80+	if !approved {81+		return 182+	}83+	// The resulting tree is the working tree with the change applied, so its84+	// object bytes come from disk.85+	save := history.commit_files(store, store.root, plan.files, history.SaveInput{86+		message: '${verb}: ${source.id}'87+	}) or { return refuse(args.json(), err.msg()) }88+	remove := not_in(current.files, plan.files)89+	report := snapshot.restore_files(store, store.root, snapshot.load(store.root), plan.files,90+		remove) or { return refuse(args.json(), err.msg()) }91+	if args.json() {92+		emit_json(json.encode(PluckedJson{93+			id:      save.id94+			source:  source.id95+			reverse: reverse96+			applied: plan.applied97+			written: report.written98+			removed: report.removed99+		}))100+		return 0101+	}102+	ok('plucked [${source.id}] into [${save.id}]')103+	return 0104+}105+106+// not_in lists the paths of `before` that `after` no longer has.107+fn not_in(before []snapshot.FileEntry, after []snapshot.FileEntry) []string {108+	mut kept := map[string]bool{}109+	for entry in after {110+		kept[entry.path] = true111+	}112+	mut missing := []string{}113+	for entry in before {114+		if entry.path !in kept {115+			missing << entry.path116+		}117+	}118+	return missing119+}120+121+struct PluckJson {122+	source     string123+	reverse    bool124+	applied    []string125+	applied_ok bool126+}127+128+struct PluckedJson {129+	id      string130+	source  string131+	reverse bool132+	applied []string133+	written int134+	removed int135+}

view file

src/cli/cmd_save.v+12 −9
@@ -3,6 +3,7 @@33 module cli44 55 import json6+import src.fmt67 import src.history78 import src.snapshot89 @@ -36,13 +37,13 @@3637 	if args.json() {3738 		emit_json(json.encode(SaveOut{3839 			id:            save.id39-			parent:        optional(save.parent)40+			parent:        save.parent4041 			message:       save.message4142 			created_at:    save.created_at4243 			machine:       save.machine4344 			is_checkpoint: save.is_checkpoint44-			agent:         optional(save.agent)45-			session:       optional(save.session)45+			agent:         save.agent46+			session:       save.session4647 			files:         files.len4748 			size_bytes:    save.size_bytes4849 		}))@@ -53,9 +54,9 @@5354 	if save.is_checkpoint {5455 		detail << 'checkpoint'5556 	}56-	detail << stamp(save.created_at)57-	detail << plural(files.len, 'file')58-	detail << human_bytes(save.size_bytes)57+	detail << fmt.stamp(save.created_at)58+	detail << fmt.plural(files.len, 'file')59+	detail << fmt.human_bytes(save.size_bytes)5960 	note(detail.join(' · '))6061 	mut attribution := []string{}6162 	if save.agent != '' {@@ -70,15 +71,17 @@7071 	return 07172 }7273 74+// Every field is always present, so a consumer never has to tell "absent" from75+// "missing key". An empty string means the save recorded none.7376 struct SaveOut {7477 	id            string75-	parent        ?string78+	parent        string7679 	message       string7780 	created_at    i647881 	machine       string7982 	is_checkpoint bool80-	agent         ?string81-	session       ?string83+	agent         string84+	session       string8285 	files         int8386 	size_bytes    i648487 }

view file

src/cli/cmd_strip.v+162 −0
@@ -0,0 +1,162 @@1+//! `koh strip` — remove a path from history, permanently.2+//!3+//! This is the only command that destroys information, so it is deliberately4+//! loud: it needs a terminal, it warns about exactly what it will destroy, and5+//! it asks twice — once the strict `y`, then the path typed out in full. Every6+//! step is safe to repeat, so an interrupted strip finishes when it is re-run.7+//!8+//! The working tree is never touched. Afterwards the file is still on disk and9+//! still untracked by the saves it was removed from, so the reminder to delete10+//! or ignore it matters.11+12+module cli13+14+import json15+import src.fmt16+import src.events17+import src.history18+import src.snapshot19+import src.storage20+21+pub fn cmd_strip(raw []string) int {22+	args := parse('strip', raw, Options{23+		values: ['save']24+		flags:  ['json']25+	}) or { return usage_error(err.msg()) }26+	path := args.positional_at(0)27+	if path == '' {28+		return usage_error('strip needs a path, e.g. koh strip secrets.env')29+	}30+	// There is no --yes: a person has to be at the keyboard for this one.31+	if !interactive() {32+		return refuse(args.json(), 'strip needs a terminal — an agent can never strip')33+	}34+	store := open_store() or { return refuse(args.json(), err.msg()) }35+	defer {36+		store.close()37+	}38+	mut only_save := ''39+	if given := args.value_or_none('save') {40+		save := history.resolve(store, given) or { return refuse(args.json(), err.msg()) }41+		only_save = save.id42+	}43+	rows := affected_rows(store, path, only_save) or { return refuse(args.json(), err.msg()) }44+	if rows.len == 0 {45+		ok('nothing in history matches ${path}')46+		return 047+	}48+	mut saves := map[string]bool{}49+	mut objects := map[string]bool{}50+	for row in rows {51+		saves[row.save_id] = true52+		objects[row.hash] = true53+	}54+	scope := if only_save == '' { 'every save' } else { 'save ${only_save}' }55+56+	println('  ${red(fmt.glyph_fail)} this cannot be undone — strip is the only command that destroys information')57+	println('  ${amber('!')} ${fmt.plural(rows.len, 'file')} in ${fmt.plural(saves.len, 'save')}, ${fmt.plural(objects.len,58+		'object')} — ${scope}')59+	println('  ${dim('strip removes the path from history; the file on disk is left alone')}')60+	approved := confirm(false) or { return refuse(args.json(), err.msg()) }61+	if !approved {62+		note('nothing was stripped')63+		return 164+	}65+	if !typed_confirm('type the path to confirm:', path) {66+		note('nothing was stripped — the path did not match')67+		return 168+	}69+70+	store.begin() or { return refuse(args.json(), err.msg()) }71+	count := remove_rows(store, rows) or {72+		store.rollback()73+		return refuse(args.json(), err.msg())74+	}75+	dropped := store.drop_unreferenced_objects() or {76+		store.rollback()77+		return refuse(args.json(), err.msg())78+	}79+	events.strip(store, only_save, path) or {80+		store.rollback()81+		return refuse(args.json(), err.msg())82+	}83+	store.commit() or { return refuse(args.json(), err.msg()) }84+85+	if args.json() {86+		emit_json(json.encode(StripJson{87+			path:            path88+			save:            only_save89+			files_removed:   count90+			objects_dropped: int(dropped)91+		}))92+		return 093+	}94+	ok('stripped ${path} from ${fmt.plural(count, 'file')} across ${fmt.plural(saves.len, 'save')}')95+	note('${dropped} objects dropped')96+	note('remember to delete ${path} or add it to ${snapshot.ignore_file}')97+	return 098+}99+100+pub struct StripRow {101+pub:102+	save_id string103+	path    string104+	hash    string105+}106+107+// affected_rows finds every saved file the path covers: the file itself, or108+// anything beneath it when it names a folder.109+fn affected_rows(store &storage.Store, path string, only_save string) ![]StripRow {110+	mut query := "SELECT save_id, path, hash FROM save_files WHERE (path = ?1 OR path LIKE ?2 ESCAPE '\\')"111+	if only_save != '' {112+		query += ' AND save_id = ?3'113+	}114+	mut stmt := store.db.prepare(query)!115+	defer {116+		stmt.finalize()117+	}118+	stmt.bind_text(1, path)!119+	stmt.bind_text(2, like_under(path))!120+	if only_save != '' {121+		stmt.bind_text(3, only_save)!122+	}123+	mut rows := []StripRow{}124+	for stmt.step()! {125+		rows << StripRow{126+			save_id: stmt.text(0)127+			path:    stmt.text(1)128+			hash:    stmt.text(2)129+		}130+	}131+	return rows132+}133+134+fn remove_rows(store &storage.Store, rows []StripRow) !int {135+	mut stmt := store.db.prepare('DELETE FROM save_files WHERE save_id = ? AND path = ?')!136+	defer {137+		stmt.finalize()138+	}139+	mut removed := 0140+	for row in rows {141+		stmt.bind_text(1, row.save_id)!142+		stmt.bind_text(2, row.path)!143+		stmt.step()!144+		removed += store.db.changes()145+		stmt.reset()146+	}147+	return removed148+}149+150+// like_under escapes LIKE's own wildcards so a path containing `%` or `_`151+// matches itself and nothing else.152+fn like_under(folder string) string {153+	escaped := folder.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')154+	return '${escaped}/%'155+}156+157+struct StripJson {158+	path            string159+	save            string160+	files_removed   int161+	objects_dropped int162+}

view file

src/cli/format.v+0 −41
@@ -1,41 +0,0 @@1-//! Small formatting helpers shared by the commands.2-3-module cli4-5-import time6-7-pub fn stamp(unix_seconds i64) string {8-	return time.unix(unix_seconds).local().format()9-}10-11-pub fn day(unix_seconds i64) string {12-	return time.unix(unix_seconds).local().ymmdd()13-}14-15-pub fn clock(unix_seconds i64) string {16-	return time.unix(unix_seconds).local().format_ss()17-}18-19-pub fn human_bytes(bytes i64) string {20-	mut value := f64(bytes)21-	mut unit := 'B'22-	for suffix in ['KiB', 'MiB', 'GiB'] {23-		if value < 1024 {24-			break25-		}26-		value /= 102427-		unit = suffix28-	}29-	if unit == 'B' {30-		return '${bytes} B'31-	}32-	return '${value:.1f} ${unit}'33-}34-35-// plural renders "1 file" / "3 files".36-pub fn plural(count int, noun string) string {37-	if count == 1 {38-		return '1 ${noun}'39-	}40-	return '${count} ${noun}s'41-}

view file at the parent

src/cli/output.v+16 −28
@@ -1,6 +1,7 @@1-//! Output formatting: the ✦ / ✗ habit, colour that can be turned off, and the2-//! `--json` shapes agents read.1+//! Output for the command line: the ✦ / ✗ habit, and the `--json` shapes2+//! agents read.33 //!4+//! Colour comes from `src.fmt`, so turning it off turns it off everywhere.45 //! Every command that prints data has a JSON form with stable field names, and56 //! every error is `{"error": "..."}` on stderr. Exit codes are fixed: 067 //! success, 1 refusal or error, 2 bad usage.@@ -8,56 +9,43 @@89 module cli910 1011 import json11-import os12+import src.fmt1213 13-const glyph_ok = '✦'14-const glyph_fail = '✗'15-1614 pub fn color_enabled() bool {17-	if os.getenv('NO_COLOR') != '' || os.getenv('KOH_NO_COLOR') != '' {18-		return false19-	}20-	return os.is_atty(1) != 015+	return fmt.colour_enabled()2116 }2217 23-fn paint(text string, code string) string {24-	if !color_enabled() {25-		return text26-	}27-	return '\x1b[${code}m${text}\x1b[0m'28-}29-3018 pub fn amber(text string) string {31-	return paint(text, '38;5;214')19+	return fmt.amber(text)3220 }3321 3422 pub fn red(text string) string {35-	return paint(text, '38;5;203')23+	return fmt.red(text)3624 }3725 38-pub fn dim(text string) string {39-	return paint(text, '38;5;245')26+pub fn green(text string) string {27+	return fmt.green(text)4028 }4129 42-pub fn green(text string) string {43-	return paint(text, '38;5;114')30+pub fn dim(text string) string {31+	return fmt.dim(text)4432 }4533 4634 pub fn ok(message string) {47-	println('  ${amber(glyph_ok)} ${message}')35+	println('  ${fmt.amber(fmt.glyph_ok)} ${message}')4836 }4937 5038 pub fn note(message string) {51-	println('  ${dim(message)}')39+	println('  ${fmt.dim(message)}')5240 }5341 5442 pub fn warn(message string) {55-	println('  ${amber('!')} ${message}')43+	println('  ${fmt.amber('!')} ${message}')5644 }5745 5846 // fail prints a refusal and returns the exit code to hand back.5947 pub fn fail(message string) int {60-	eprintln('  ${red(glyph_fail)} ${message}')48+	eprintln('  ${fmt.red(fmt.glyph_fail)} ${message}')6149 	return 16250 }6351 @@ -69,7 +57,7 @@6957 }7058 7159 pub fn usage_error(message string) int {72-	eprintln('  ${red(glyph_fail)} ${message}')60+	eprintln('  ${fmt.red(fmt.glyph_fail)} ${message}')7361 	return 27462 }7563 

view file

src/events/events.v+240 −0
@@ -0,0 +1,240 @@1+//! Events: what Koh learns about saves that already exist.2+//!3+//! Save records are write-once, so a flag is not stored on the save. It is4+//! appended as a small event that syncs alongside saves, and a save's flag5+//! state is always derived from the newest event about it — never stored twice.6+//! A strip event is what makes `strip` visible to other machines.7+8+module events9+10+import strings11+import time12+import src.machine13+import src.storage14+15+pub const kind_flag = 'flag'16+pub const kind_clear = 'clear'17+pub const kind_strip = 'strip'18+19+// An event about one save.20+pub struct Event {21+pub mut:22+	id         string23+	kind       string24+	save_id    string25+	path       string26+	reason     string27+	created_at i6428+	machine    string29+}30+31+// Flag is the derived answer to "is this save flagged, and why".32+pub struct Flag {33+pub:34+	save_id string35+	flagged bool36+	reason  string37+	at      i6438+}39+40+const select_event = 'SELECT id, kind, save_id, path, reason, created_at, machine FROM events'41+42+fn read_event(stmt &storage.Stmt) Event {43+	return Event{44+		id:         stmt.text(0)45+		kind:       stmt.text(1)46+		save_id:    stmt.text_or_none(2) or { '' }47+		path:       stmt.text_or_none(3) or { '' }48+		reason:     stmt.text_or_none(4) or { '' }49+		created_at: stmt.int(5)50+		machine:    stmt.text(6)51+	}52+}53+54+// write appends an event. The ID is the first 12 hex characters of the Blake355+// hash of the record, so two machines that record the same fact agree.56+pub fn write(store &storage.Store, event Event) !Event {57+	mut record := event58+	if record.created_at == 0 {59+		// Events are ordered by time, and flag/clear can land in the same60+		// second. Bumping past the newest event keeps "newest wins" true, and61+		// keeps the ordering the same on every machine.62+		record.created_at = next_timestamp(store)!63+	}64+	if record.machine == '' {65+		record.machine = machine.name()66+	}67+	record.id = event_id(record)68+	insert(store, record)!69+	return record70+}71+72+// write_once appends an event whose ID covers only what it says, not when it73+// was said. Re-running an interrupted strip therefore records the same fact74+// instead of a second one.75+pub fn write_once(store &storage.Store, event Event) !Event {76+	mut record := event77+	if record.created_at == 0 {78+		record.created_at = time.now().unix()79+	}80+	if record.machine == '' {81+		record.machine = machine.name()82+	}83+	record.id = storage.hash_hex('${record.kind}:${record.save_id}:${record.path}'.bytes())[..12]84+	insert(store, record)!85+	return record86+}87+88+fn next_timestamp(store &storage.Store) !i64 {89+	now := time.now().unix()90+	mut stmt := store.db.prepare('SELECT MAX(created_at) FROM events')!91+	defer {92+		stmt.finalize()93+	}94+	if !stmt.step()! {95+		return now96+	}97+	newest := stmt.int_or_none(0) or { return now }98+	if newest >= now {99+		return newest + 1100+	}101+	return now102+}103+104+fn insert(store &storage.Store, record Event) ! {105+	mut stmt := store.db.prepare('INSERT OR IGNORE INTO events106+		(id, kind, save_id, path, reason, created_at, machine) VALUES (?, ?, ?, ?, ?, ?, ?)')!107+	defer {108+		stmt.finalize()109+	}110+	stmt.bind_text(1, record.id)!111+	stmt.bind_text(2, record.kind)!112+	bind_optional(stmt, 3, record.save_id)!113+	bind_optional(stmt, 4, record.path)!114+	bind_optional(stmt, 5, record.reason)!115+	stmt.bind_int(6, record.created_at)!116+	stmt.bind_text(7, record.machine)!117+	stmt.step()!118+}119+120+pub fn flag(store &storage.Store, save_id string, reason string) !Event {121+	return write(store, Event{122+		kind:    kind_flag123+		save_id: save_id124+		reason:  reason125+	})!126+}127+128+pub fn clear_flag(store &storage.Store, save_id string) !Event {129+	return write(store, Event{130+		kind:    kind_clear131+		save_id: save_id132+	})!133+}134+135+// strip records that a path was removed from a save (or from every save, when136+// save_id is empty).137+pub fn strip(store &storage.Store, save_id string, path string) !Event {138+	return write_once(store, Event{139+		kind:    kind_strip140+		save_id: save_id141+		path:    path142+	})!143+}144+145+pub fn event_id(event Event) string {146+	mut sb := strings.Builder{}147+	put(mut sb, 'kind', event.kind)148+	put(mut sb, 'save', event.save_id)149+	put(mut sb, 'path', event.path)150+	put(mut sb, 'reason', event.reason)151+	put(mut sb, 'created', event.created_at.str())152+	put(mut sb, 'machine', event.machine)153+	return storage.hash_hex(sb.str().bytes())[..12]154+}155+156+fn put(mut sb strings.Builder, name string, value string) {157+	sb.write_string('${name}:${value.len}:')158+	sb.write_string(value)159+	sb.write_string('\n')160+}161+162+fn bind_optional(stmt &storage.Stmt, index int, value string) ! {163+	if value == '' {164+		stmt.bind_null(index)!165+	} else {166+		stmt.bind_text(index, value)!167+	}168+}169+170+// all returns every event, oldest first.171+pub fn all(store &storage.Store) ![]Event {172+	mut stmt := store.db.prepare('${select_event} ORDER BY created_at, id')!173+	defer {174+		stmt.finalize()175+	}176+	mut out := []Event{}177+	for stmt.step()! {178+		out << read_event(stmt)179+	}180+	return out181+}182+183+// latest_flags derives the flag state of every save that has ever been flagged184+// or cleared. Newest wins, ties broken by ID so the answer is the same on every185+// machine.186+pub fn latest_flags(store &storage.Store) !map[string]Flag {187+	mut stmt := store.db.prepare('${select_event} WHERE kind IN (?, ?) AND save_id IS NOT NULL188+		ORDER BY created_at, id')!189+	defer {190+		stmt.finalize()191+	}192+	stmt.bind_text(1, kind_flag)!193+	stmt.bind_text(2, kind_clear)!194+	mut flags := map[string]Flag{}195+	for stmt.step()! {196+		event := read_event(stmt)197+		flags[event.save_id] = Flag{198+			save_id: event.save_id199+			flagged: event.kind == kind_flag200+			reason:  event.reason201+			at:      event.created_at202+		}203+	}204+	return flags205+}206+207+pub fn flag_state(store &storage.Store, save_id string) !Flag {208+	flags := latest_flags(store)!209+	return flags[save_id] or {210+		Flag{211+			save_id: save_id212+		}213+	}214+}215+216+// flagged_ids is what the log and the history view mark.217+pub fn flagged_ids(store &storage.Store) ![]string {218+	flags := latest_flags(store)!219+	mut ids := []string{}220+	for save_id, state in flags {221+		if state.flagged {222+			ids << save_id223+		}224+	}225+	ids.sort()226+	return ids227+}228+229+pub fn strip_events(store &storage.Store) ![]Event {230+	mut stmt := store.db.prepare('${select_event} WHERE kind = ? ORDER BY created_at, id')!231+	defer {232+		stmt.finalize()233+	}234+	stmt.bind_text(1, kind_strip)!235+	mut out := []Event{}236+	for stmt.step()! {237+		out << read_event(stmt)238+	}239+	return out240+}

view file

src/events/events_test.v+76 −0
@@ -0,0 +1,76 @@1+module events2+3+import os4+import src.history5+import src.snapshot6+import src.storage7+8+fn make_repo(name string) !storage.Store {9+	dir := os.join_path(os.temp_dir(), 'koh3_events_${os.getpid()}_${name}')10+	os.rmdir_all(dir) or {}11+	os.mkdir_all(dir, os.MkdirParams{})!12+	return storage.create(dir)!13+}14+15+fn test_flags_are_derived_from_the_newest_event() {16+	mut store := make_repo('flags')!17+	defer {18+		store.close()19+		os.rmdir_all(store.root) or {}20+	}21+	os.write_file(os.join_path(store.root, 'a.txt'), 'hello\n')!22+	save := history.commit_worktree(store, store.root, snapshot.Ignore{}, history.SaveInput{23+		message: 'first'24+	})!25+26+	// Unflagged to begin with: absence of an event is the flag state.27+	assert !flag_state(store, save.id)!.flagged28+29+	flag(store, save.id, 'broken build')!30+	state := flag_state(store, save.id)!31+	assert state.flagged32+	assert state.reason == 'broken build'33+	assert flagged_ids(store)! == [save.id]34+35+	clear_flag(store, save.id)!36+	assert !flag_state(store, save.id)!.flagged37+	assert flagged_ids(store)!.len == 038+39+	// Clearing is itself an event, so flagging stays undoable: the events are40+	// all still there, newest simply wins — even when both land in the same41+	// second, which is the normal case for an agent.42+	all_events := all(store)!43+	assert all_events.len == 244+	assert all_events[0].kind == kind_flag45+	assert all_events[1].kind == kind_clear46+	assert all_events[1].created_at > all_events[0].created_at47+}48+49+fn test_event_ids_are_stable_for_the_same_record() {50+	first := Event{51+		kind:       kind_flag52+		save_id:    'abcdef123456'53+		reason:     'why'54+		created_at: 179000000055+		machine:    'here'56+	}57+	second := first58+	assert event_id(first) == event_id(second)59+	assert event_id(first).len == 1260+	mut other := first61+	other.reason = 'different'62+	assert event_id(other) != event_id(first)63+}64+65+fn test_strip_events_are_recorded_separately() {66+	mut store := make_repo('strip')!67+	defer {68+		store.close()69+		os.rmdir_all(store.root) or {}70+	}71+	strip(store, 'abc123abc123', 'secrets.txt')!72+	events := strip_events(store)!73+	assert events.len == 174+	assert events[0].path == 'secrets.txt'75+	assert events[0].save_id == 'abc123abc123'76+}

view file

src/fmt/fmt.v+111 −0
@@ -0,0 +1,111 @@1+//! Presentation primitives shared by the two places Koh prints: the command2+//! line and the full-screen history view.3+//!4+//! Colour lives here so both surfaces use one palette, and `NO_COLOR` /5+//! `KOH_NO_COLOR` turn it off for both at once.6+7+module fmt8+9+import os10+import time11+12+pub const glyph_ok = '✦'13+pub const glyph_fail = '✗'14+15+pub fn colour_enabled() bool {16+	if os.getenv('NO_COLOR') != '' || os.getenv('KOH_NO_COLOR') != '' {17+		return false18+	}19+	return os.is_atty(1) != 020+}21+22+fn paint(text string, code string) string {23+	if !colour_enabled() {24+		return text25+	}26+	return '\x1b[${code}m${text}\x1b[0m'27+}28+29+pub fn bold(text string) string {30+	return paint(text, '1')31+}32+33+pub fn reverse(text string) string {34+	return paint(text, '7')35+}36+37+pub fn amber(text string) string {38+	return paint(text, '38;5;214')39+}40+41+pub fn red(text string) string {42+	return paint(text, '38;5;203')43+}44+45+pub fn green(text string) string {46+	return paint(text, '38;5;114')47+}48+49+pub fn dim(text string) string {50+	return paint(text, '38;5;245')51+}52+53+// strip_ansi removes the colour codes, for measuring how wide a line really is.54+pub fn strip_ansi(text string) string {55+	mut out := ''56+	mut skipping := false57+	for c in text.runes() {58+		if c == `\x1b` {59+			skipping = true60+			continue61+		}62+		if skipping {63+			if (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) {64+				skipping = false65+			}66+			continue67+		}68+		out += c.str()69+	}70+	return out71+}72+73+pub fn visible_width(text string) int {74+	return strip_ansi(text).runes().len75+}76+77+pub fn stamp(unix_seconds i64) string {78+	return time.unix(unix_seconds).local().format()79+}80+81+pub fn date_of(unix_seconds i64) string {82+	return time.unix(unix_seconds).local().ymmdd()83+}84+85+pub fn clock_of(unix_seconds i64) string {86+	return time.unix(unix_seconds).local().hhmmss()87+}88+89+pub fn human_bytes(bytes i64) string {90+	mut value := f64(bytes)91+	mut unit := 'B'92+	for suffix in ['KiB', 'MiB', 'GiB'] {93+		if value < 1024 {94+			break95+		}96+		value /= 102497+		unit = suffix98+	}99+	if unit == 'B' {100+		return '${bytes} B'101+	}102+	return '${value:.1f} ${unit}'103+}104+105+// plural renders "1 file" / "3 files".106+pub fn plural(count int, noun string) string {107+	if count == 1 {108+		return '1 ${noun}'109+	}110+	return '${count} ${noun}s'111+}

view file

src/history/commit.v+3 −4
@@ -39,13 +39,11 @@3939 			}4040 		}4141 	}42-	save := commit_files(store, root, files, input)!43-	write_log_md(store, root)!44-	return save42+	return commit_files(store, root, files, input)!4543 }4644 4745 // commit_files records an already-known tree. `root` is '' when the objects are48-// already in the store — a replay during a steal, for instance.46+// already in the store — a restore, or a replay during a steal.4947 pub fn commit_files(store &storage.Store, root string, files []snapshot.FileEntry, input SaveInput) !Save {5048 	store.begin()!5149 	save := write_save(store, root, files, input) or {@@ -53,6 +51,7 @@5351 		return err5452 	}5553 	store.commit()!54+	write_log_md(store)!5655 	return save5756 }5857 

view file

src/history/log_md.v+4 −2
@@ -10,8 +10,10 @@1010 const log_marker = '<!-- Koh maintains this file. Do not edit. Read before starting work. -->'1111 const log_shown = 201212 13-pub fn write_log_md(store &storage.Store, root string) ! {14-	path := os.join_path(root, 'LOG.md')13+// write_log_md refreshes LOG.md from the line. It runs after every save,14+// whatever made it — save, checkpoint, undo, load, revert, pluck or a steal.15+pub fn write_log_md(store &storage.Store) ! {16+	path := os.join_path(store.root, 'LOG.md')1517 	if os.is_file(path) {1618 		existing := os.read_file(path)!1719 		if !existing.starts_with(log_marker) {

view file

src/view/term.v+183 −0
@@ -0,0 +1,183 @@1+//! Terminal control and key decoding for the history view.2+//!3+//! The view owns the screen while it runs, so raw mode and the alternate screen4+//! have to be entered and left reliably, on every exit path. Rendering is a5+//! plain string of ANSI escapes written in one call per frame — no redraw6+//! diffing, because the whole frame is small and the terminal does that faster7+//! than we can.8+9+module view10+11+#flag -I@VMODROOT/vendor/shim12+#flag @VMODROOT/vendor/shim/koh_term.c13+#include "koh_term.h"14+15+fn C.koh_term_raw(enable int) int16+17+fn C.koh_term_size(rows &int, cols &int) int18+19+fn C.koh_read_key(buf &char, cap int) int20+21+pub enum KeyKind {22+	rune23+	up24+	down25+	left26+	right27+	enter28+	escape29+	tab30+	backspace31+	delete32+	home33+	end34+	page_up35+	page_down36+	interrupt37+	eof38+	unknown39+}40+41+pub struct Key {42+pub:43+	kind KeyKind44+	text string45+}46+47+pub fn terminal_size() (int, int) {48+	mut rows := 049+	mut cols := 050+	C.koh_term_size(&rows, &cols)51+	return rows, cols52+}53+54+pub fn raw_mode(enable bool) {55+	if !enable {56+		C.koh_term_raw(0)57+		return58+	}59+	C.koh_term_raw(1)60+}61+62+// read_key blocks for one keystroke. `eof` means input ended (stdin closed or63+// Ctrl-D), which the view treats as quit.64+pub fn read_key() Key {65+	mut buf := []u8{len: 16}66+	n := C.koh_read_key(unsafe { &char(buf.data) }, buf.len)67+	if n <= 0 {68+		return Key{69+			kind: .eof70+		}71+	}72+	return decode(buf[..n])73+}74+75+fn decode(bytes []u8) Key {76+	if bytes.len == 1 {77+		b := bytes[0]78+		match b {79+			0x03 {80+				return Key{81+					kind: .interrupt82+				}83+			}84+			0x04 {85+				return Key{86+					kind: .eof87+				}88+			}89+			0x09 {90+				return Key{91+					kind: .tab92+				}93+			}94+			0x0d, 0x0a {95+				return Key{96+					kind: .enter97+				}98+			}99+			0x7f, 0x08 {100+				return Key{101+					kind: .backspace102+				}103+			}104+			0x1b {105+				return Key{106+					kind: .escape107+				}108+			}109+			else {110+				if b >= 0x20 {111+					return Key{112+						kind: .rune113+						text: bytes.bytestr()114+					}115+				}116+				return Key{117+					kind: .unknown118+				}119+			}120+		}121+	}122+	if bytes[0] == 0x1b {123+		sequence := bytes[1..].bytestr()124+		return match sequence {125+			'[A', 'OA' {126+				Key{127+					kind: .up128+				}129+			}130+			'[B', 'OB' {131+				Key{132+					kind: .down133+				}134+			}135+			'[C', 'OC' {136+				Key{137+					kind: .right138+				}139+			}140+			'[D', 'OD' {141+				Key{142+					kind: .left143+				}144+			}145+			'[H', 'OH', '[1~' {146+				Key{147+					kind: .home148+				}149+			}150+			'[F', 'OF', '[4~' {151+				Key{152+					kind: .end153+				}154+			}155+			'[3~' {156+				Key{157+					kind: .delete158+				}159+			}160+			'[5~' {161+				Key{162+					kind: .page_up163+				}164+			}165+			'[6~' {166+				Key{167+					kind: .page_down168+				}169+			}170+			else {171+				Key{172+					kind: .escape173+				}174+			}175+		}176+	}177+	// Anything else is typed text: a pasted run, or a multi-byte character the178+	// shim gathered in one read.179+	return Key{180+		kind: .rune181+		text: bytes.bytestr()182+	}183+}

view file

src/view/term_test.v+29 −0
@@ -0,0 +1,29 @@1+module view2+3+fn test_arrow_keys_are_decoded() {4+	assert decode([u8(0x1b), u8(`[`), u8(`A`)]).kind == .up5+	assert decode([u8(0x1b), u8(`[`), u8(`B`)]).kind == .down6+	assert decode([u8(0x1b), u8(`[`), u8(`C`)]).kind == .right7+	assert decode([u8(0x1b), u8(`[`), u8(`D`)]).kind == .left8+	assert decode([u8(0x1b), u8(`[`), u8(`5`), u8(`~`)]).kind == .page_up9+	assert decode([u8(0x1b), u8(`[`), u8(`6`), u8(`~`)]).kind == .page_down10+}11+12+fn test_single_keys_are_decoded() {13+	assert decode([u8(0x0d)]).kind == .enter14+	assert decode([u8(0x7f)]).kind == .backspace15+	assert decode([u8(0x09)]).kind == .tab16+	assert decode([u8(0x03)]).kind == .interrupt17+	assert decode([u8(0x04)]).kind == .eof18+	assert decode([u8(0x1b)]).kind == .escape19+	assert decode([u8(`q`)]).kind == .rune20+	assert decode([u8(`q`)]).text == 'q'21+	assert decode([u8(`/`)]).text == '/'22+}23+24+fn test_typed_text_keeps_its_bytes() {25+	// A pasted run arrives in one read and must stay together, and a multi-byte26+	// character must not be split into mojibake.27+	assert decode('search'.bytes()).text == 'search'28+	assert decode('✦'.bytes()).text == '✦'29+}

view file

src/view/view.v+619 −0
@@ -0,0 +1,619 @@1+//! The history view: the saves and checkpoints list, full screen.2+//!3+//! On a terminal `koh log` opens this instead of printing; `koh saves` and4+//! `koh checkpoints` open it on their tab. The view only reads — history, flags5+//! and diffs — and hands any decision back to the caller as an `Action`, so the6+//! command layer stays the only thing that changes anything.7+//!8+//! Everything a frame needs is in `Screen`, and `render` turns one into the9+//! text of a frame. That split keeps the interesting part testable without a10+//! terminal: the tests here never run the event loop.11+12+module view13+14+import encoding.base6415+import os16+import strings17+import src.events18+import src.fmt19+import src.history20+import src.snapshot21+import src.storage22+23+pub enum Tab {24+	all25+	saves26+	checkpoints27+}28+29+pub enum ActionKind {30+	none31+	load32+	flag33+	clear_flag34+}35+36+pub struct Action {37+pub mut:38+	kind    ActionKind39+	save_id string40+	reason  string41+}42+43+// TabCounts is what the tab bar shows: how many saves are in each list before44+// the search narrows anything.45+pub struct TabCounts {46+pub mut:47+	all         int48+	saves       int49+	checkpoints int50+}51+52+// Screen is one frame's worth of state.53+pub struct Screen {54+pub mut:55+	tab      Tab56+	items    []history.Save57+	counts   TabCounts58+	selected int59+	query    string60+	status   string61+	flags    map[string]events.Flag62+	on_line  map[string]bool63+	rows     int64+	cols     int65+}66+67+enum Mode {68+	list69+	search70+	reason71+	detail72+}73+74+// Session is everything the event loop changes: what is on screen, which mode75+// it is in, and what the current action is about.76+struct Session {77+mut:78+	screen Screen79+	mode   Mode80+	target history.Save81+	typed  string82+}83+84+// run shows the view until the user leaves it. `act` performs a chosen action85+// and returns the message for the status bar, so the view itself never changes86+// history.87+pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {88+	enter_screen()89+	defer {90+		leave_screen()91+	}92+	every := history.all_saves(store)!93+	on_line := on_line_set(store)94+	mut session := Session{95+		screen: Screen{96+			tab:     start97+			on_line: on_line98+			flags:   events.latest_flags(store)!99+			counts:  count_tabs(every, on_line)100+		}101+	}102+	for {103+		session.screen.rows, session.screen.cols = terminal_size()104+		session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,105+			session.screen.query)106+		if session.screen.selected >= session.screen.items.len {107+			session.screen.selected = move_selection(session.screen.selected, 0,108+				session.screen.items.len)109+		}110+		paint(frame_for(store, session))111+		key := read_key()112+		if key.kind in [.interrupt, .eof] {113+			return114+		}115+		leaving := match session.mode {116+			.list { list_key(mut session, key) }117+			.search { search_key(mut session, key) }118+			.reason { reason_key(mut session, store, key, act) }119+			.detail { detail_key(mut session, store, key, act) }120+		}121+122+		if leaving {123+			return124+		}125+	}126+}127+128+fn frame_for(store &storage.Store, session Session) string {129+	match session.mode {130+		.detail, .reason {131+			delta := snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,132+				session.target)) or { []snapshot.Delta{} }133+			return detail_frame(session.screen, session.target, delta, session.mode == .reason,134+				session.typed)135+		}136+		else {137+			return render(session.screen)138+		}139+	}140+}141+142+// list_key handles one key in the list and reports whether to leave the view.143+fn list_key(mut session Session, key Key) bool {144+	match key.kind {145+		.tab {146+			session.screen.tab = next_tab(session.screen.tab)147+			session.screen.selected = 0148+		}149+		.down {150+			session.screen.selected = move_selection(session.screen.selected, 1,151+				session.screen.items.len)152+		}153+		.up {154+			session.screen.selected = move_selection(session.screen.selected, -1,155+				session.screen.items.len)156+		}157+		.enter {158+			if session.screen.items.len > 0 {159+				session.target = session.screen.items[session.screen.selected]160+				session.mode = .detail161+			}162+		}163+		.rune {164+			match key.text {165+				'q' {166+					return true167+				}168+				'j' {169+					session.screen.selected = move_selection(session.screen.selected, 1,170+						session.screen.items.len)171+				}172+				'k' {173+					session.screen.selected = move_selection(session.screen.selected, -1,174+						session.screen.items.len)175+				}176+				'g' {177+					session.screen.selected = 0178+				}179+				'G' {180+					session.screen.selected = move_selection(session.screen.items.len, -1,181+						session.screen.items.len)182+				}183+				'/' {184+					session.mode = .search185+					session.screen.query = ''186+				}187+				'c' {188+					if session.screen.items.len > 0 {189+						id := session.screen.items[session.screen.selected].id190+						copy_id(id)191+						session.screen.status = 'copied ${id}'192+					}193+				}194+				else {}195+			}196+		}197+		else {}198+	}199+200+	return false201+}202+203+fn search_key(mut session Session, key Key) bool {204+	match key.kind {205+		.rune {206+			session.screen.query += key.text207+		}208+		.backspace {209+			session.screen.query = trim_last(session.screen.query)210+		}211+		.escape {212+			session.screen.query = ''213+			session.mode = .list214+		}215+		.enter {216+			session.mode = .list217+		}218+		else {}219+	}220+221+	return false222+}223+224+fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {225+	match key.kind {226+		.rune {227+			session.typed += key.text228+		}229+		.backspace {230+			session.typed = trim_last(session.typed)231+		}232+		.escape {233+			session.typed = ''234+			session.mode = .list235+		}236+		.enter {237+			if session.typed != '' {238+				session.screen.status = act(store, Action{239+					kind:    .flag240+					save_id: session.target.id241+					reason:  session.typed242+				})243+			}244+			session.typed = ''245+			session.mode = .list246+		}247+		else {}248+	}249+250+	return false251+}252+253+fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {254+	match key.kind {255+		.escape {256+			session.mode = .list257+		}258+		.rune {259+			match key.text {260+				'l' {261+					if ask_confirm('load [${session.target.id}]') {262+						session.screen.status = act(store, Action{263+							kind:    .load264+							save_id: session.target.id265+						})266+					}267+					session.mode = .list268+				}269+				'f' {270+					state := session.screen.flags[session.target.id] or {271+						events.Flag{272+							save_id: session.target.id273+						}274+					}275+					if state.flagged {276+						session.screen.status = act(store, Action{277+							kind:    .clear_flag278+							save_id: session.target.id279+						})280+						session.mode = .list281+					} else {282+						session.typed = ''283+						session.mode = .reason284+					}285+				}286+				else {}287+			}288+		}289+		else {}290+	}291+292+	return false293+}294+295+// filter_saves applies a tab and the search text. `on_line` marks the saves296+// still on the line: a replayed-away save exists in history but belongs to297+// neither the saves nor the checkpoints list.298+pub fn filter_saves(items []history.Save, tab Tab, on_line map[string]bool, query string) []history.Save {299+	mut out := []history.Save{}300+	needle := query.to_lower()301+	for save in items {302+		live := save.id in on_line && save.replayed_from == ''303+		if tab == .saves && !live {304+			continue305+		}306+		if tab == .checkpoints && (!live || !save.is_checkpoint) {307+			continue308+		}309+		if needle != '' && !searchable(save).contains(needle) {310+			continue311+		}312+		out << save313+	}314+	return out315+}316+317+fn searchable(save history.Save) string {318+	return '${save.id} ${save.message} ${save.agent} ${save.session}'.to_lower()319+}320+321+pub fn count_tabs(items []history.Save, on_line map[string]bool) TabCounts {322+	return TabCounts{323+		all:         filter_saves(items, .all, on_line, '').len324+		saves:       filter_saves(items, .saves, on_line, '').len325+		checkpoints: filter_saves(items, .checkpoints, on_line, '').len326+	}327+}328+329+// move_selection clamps a move to the ends of the list.330+pub fn move_selection(index int, delta int, length int) int {331+	if length <= 0 {332+		return 0333+	}334+	moved := index + delta335+	if moved < 0 {336+		return 0337+	}338+	if moved >= length {339+		return length - 1340+	}341+	return moved342+}343+344+// detail_lines describes one save: what it was, and what it changed.345+pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {346+	mut lines := [347+		'  ${fmt.bold('[' + save.id + ']')}  ${save.message}',348+		'',349+		'  time      ${fmt.stamp(save.created_at)}',350+		'  size      ${fmt.human_bytes(save.size_bytes)}',351+		'  agent     ${or_dash(save.agent)}',352+		'  session   ${or_dash(save.session)}',353+		'  parent    ${or_dash(save.parent)}',354+		'  checksum  ${save.id}',355+	]356+	if flag.flagged {357+		lines << '  flag      ${or_dash(flag.reason)}'358+	}359+	lines << ''360+	if deltas.len == 0 {361+		lines << '  ${fmt.dim('nothing changed against the parent')}'362+		return lines363+	}364+	lines << '  ${fmt.plural(deltas.len, 'file')} changed against the parent:'365+	for delta in deltas {366+		lines << '  ${change_mark(delta)} ${delta.path}${line_stats(delta)}'367+	}368+	return lines369+}370+371+fn line_stats(delta snapshot.Delta) string {372+	if delta.binary {373+		return '  ${fmt.dim('(binary)')}'374+	}375+	return '  ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'376+}377+378+fn change_mark(delta snapshot.Delta) string {379+	return match delta.kind {380+		'added' { fmt.green('+') }381+		'removed' { fmt.red('-') }382+		else { fmt.amber('~') }383+	}384+}385+386+// render is a whole frame of the list: tabs, entries, status bar.387+pub fn render(screen Screen) string {388+	mut out := strings.Builder{}389+	out.write_string('\x1b[H')390+	for line in frame_lines(screen) {391+		out.write_string('\x1b[2K${line}\r\n')392+	}393+	return out.str()394+}395+396+pub fn frame_lines(screen Screen) []string {397+	mut lines := [tab_bar(screen)]398+	mut body := []string{}399+	for i, save in screen.items {400+		body << entry_line(save, i == screen.selected, screen)401+		if i == screen.selected {402+			body << meta_line(save)403+		}404+		body << fmt.dim('  ${'─'.repeat(separator_width(screen.cols))}')405+	}406+	if body.len == 0 {407+		body << fmt.dim('  no results')408+	}409+	room := body_room(screen.rows)410+	start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }411+	for i := start; i < body.len && lines.len < room + 1; i++ {412+		lines << body[i]413+	}414+	for lines.len < room + 1 {415+		lines << ''416+	}417+	lines << status_bar(screen)418+	return lines419+}420+421+fn body_room(rows int) int {422+	// The tab bar and the status bar take one row each; two more rows keep the423+	// list off the very bottom of the screen.424+	if rows <= 4 {425+		return 1426+	}427+	return rows - 3428+}429+430+fn separator_width(cols int) int {431+	if cols <= 6 {432+		return 1433+	}434+	return cols - 4435+}436+437+fn tab_bar(screen Screen) string {438+	names := ['all', 'saves', 'checkpoints']439+	counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]440+	mut parts := []string{}441+	for i, name in names {442+		label := '${name} (${counts[i]})'443+		if tab_at(i) == screen.tab {444+			parts << fmt.bold('[${label}]')445+		} else {446+			parts << fmt.dim(label)447+		}448+	}449+	mut bar := '  ${parts.join('  ')}    '450+	if screen.query != '' {451+		bar += fmt.bold('/${screen.query}_')452+	} else {453+		bar += fmt.dim('/ search')454+	}455+	return bar456+}457+458+fn entry_line(save history.Save, selected bool, screen Screen) string {459+	mut line := '  ${if selected { fmt.amber('▸') } else { ' ' }} '460+	line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '461+	line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)}  ${save.message}'462+	mut tags := []string{}463+	if save.agent != '' || save.session != '' {464+		tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'465+	}466+	if save.restores != '' {467+		tags << 'back to ${save.restores}'468+	}469+	if save.replayed_from != '' {470+		tags << 'replayed'471+	}472+	if tags.len > 0 {473+		line += '   ${fmt.dim(tags.join('  '))}'474+	}475+	if selected {476+		return fmt.reverse(line)477+	}478+	return line479+}480+481+fn meta_line(save history.Save) string {482+	return fmt.dim('         ${fmt.human_bytes(save.size_bytes)}  ·  agent ${or_dash(save.agent)}  ·  session ${or_dash(save.session)}')483+}484+485+fn status_bar(screen Screen) string {486+	mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'487+	if screen.status != '' {488+		hints = screen.status489+	}490+	left := 'view: ${tab_name(screen.tab)} · ${hints}'491+	right := '${screen.items.len} shown'492+	mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3493+	if gap < 1 {494+		gap = 1495+	}496+	return fmt.dim('  ${left}${' '.repeat(gap)}${right}')497+}498+499+fn save_mark(save history.Save, screen Screen) string {500+	if save.is_checkpoint {501+		return fmt.amber('◐')502+	}503+	if (screen.flags[save.id] or {504+		events.Flag{505+			save_id: save.id506+		}507+	}).flagged {508+		return fmt.red('⚑')509+	}510+	if save.replayed_from != '' {511+		return fmt.dim('↪')512+	}513+	return ' '514+}515+516+fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {517+	flag := screen.flags[target.id] or {518+		events.Flag{519+			save_id: target.id520+		}521+	}522+	mut out := strings.Builder{}523+	out.write_string('\x1b[H\x1b[2J')524+	for line in detail_lines(target, deltas, flag) {525+		out.write_string('${line}\r\n')526+	}527+	out.write_string('\r\n')528+	if asking_reason {529+		out.write_string('  ${fmt.amber('flag reason')}: ${typed}_')530+		return out.str()531+	}532+	action := if flag.flagged { 'clear flag' } else { 'flag' }533+	out.write_string(fmt.dim('  l load · f ${action} · esc back'))534+	return out.str()535+}536+537+fn trim_last(text string) string {538+	if text.len == 0 {539+		return text540+	}541+	return text[..text.len - 1]542+}543+544+// ask_confirm is the strict prompt inside the view: only a lowercase y.545+fn ask_confirm(what string) bool {546+	paint('\x1b[H\x1b[2J  ${what} — continue? [y/N] ')547+	return read_key().text == 'y'548+}549+550+// copy_id puts the ID on the clipboard through OSC 52, which works over ssh and551+// in any terminal without a helper program.552+fn copy_id(save_id string) {553+	paint('\x1b]52;c;${base64.encode(save_id.bytes())}\x07')554+}555+556+fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {557+	if save.parent == '' {558+		return snapshot.Tree{}559+	}560+	return snapshot.Tree{561+		files: history.files_of(store, save.parent) or { []snapshot.FileEntry{} }562+	}563+}564+565+fn child_tree(store &storage.Store, save history.Save) snapshot.Tree {566+	return snapshot.Tree{567+		files: history.files_of(store, save.id) or { []snapshot.FileEntry{} }568+	}569+}570+571+fn on_line_set(store &storage.Store) map[string]bool {572+	mut ids := map[string]bool{}573+	for save in history.line(store) or { []history.Save{} } {574+		ids[save.id] = true575+	}576+	return ids577+}578+579+fn enter_screen() {580+	paint('\x1b[?1049h\x1b[?25l')581+	raw_mode(true)582+}583+584+fn leave_screen() {585+	raw_mode(false)586+	paint('\x1b[?25h\x1b[?1049l')587+}588+589+fn paint(text string) {590+	os.fd_write(1, text)591+}592+593+fn tab_at(index int) Tab {594+	return match index {595+		0 { Tab.all }596+		1 { Tab.saves }597+		else { Tab.checkpoints }598+	}599+}600+601+fn tab_name(tab Tab) string {602+	return match tab {603+		.all { 'all' }604+		.saves { 'saves' }605+		else { 'checkpoints' }606+	}607+}608+609+fn next_tab(tab Tab) Tab {610+	return match tab {611+		.all { Tab.saves }612+		.saves { Tab.checkpoints }613+		else { Tab.all }614+	}615+}616+617+fn or_dash(value string) string {618+	return if value == '' { '—' } else { value }619+}

view file

src/view/view_test.v+149 −0
@@ -0,0 +1,149 @@1+module view2+3+import src.events4+import src.history5+import src.snapshot6+7+fn save(id string, message string, checkpoint bool) history.Save {8+	return history.Save{9+		id:            id10+		message:       message11+		created_at:    179000000012+		size_bytes:    102413+		is_checkpoint: checkpoint14+	}15+}16+17+fn line_map(ids []string) map[string]bool {18+	mut out := map[string]bool{}19+	for id in ids {20+		out[id] = true21+	}22+	return out23+}24+25+fn test_tabs_filter_by_what_is_on_the_line() {26+	on_the_line := line_map(['aaa', 'ccc'])27+	mut replayed := save('bbb', 'replayed away', false)28+	replayed.replayed_from = 'aaa'29+	items := [save('aaa', 'first', false), replayed, save('ccc', 'before things', true)]30+31+	assert filter_saves(items, .all, on_the_line, '').len == 332+	// A replayed-away save exists in history but is on neither live list.33+	assert filter_saves(items, .saves, on_the_line, '').map(it.id) == ['aaa', 'ccc']34+	assert filter_saves(items, .checkpoints, on_the_line, '').map(it.id) == ['ccc']35+}36+37+fn test_search_is_case_insensitive_across_the_fields() {38+	on_the_line := line_map(['aaa', 'bbb', 'ccc'])39+	mut with_agent := save('bbb', 'fix chunk boundary', false)40+	with_agent.agent = 'DeepSeek/Flash'41+	mut with_session := save('ccc', 'other work', false)42+	with_session.session = 'phase one'43+	items := [save('aaa', 'add storage', false), with_agent, with_session]44+45+	assert filter_saves(items, .all, on_the_line, 'STORAGE').map(it.id) == ['aaa']46+	assert filter_saves(items, .all, on_the_line, 'chunk').map(it.id) == ['bbb']47+	assert filter_saves(items, .all, on_the_line, 'deepseek').map(it.id) == ['bbb']48+	assert filter_saves(items, .all, on_the_line, 'PHASE').map(it.id) == ['ccc']49+	assert filter_saves(items, .all, on_the_line, 'nothing here').len == 050+}51+52+fn test_move_selection_clamps_at_both_ends() {53+	assert move_selection(0, 1, 3) == 154+	assert move_selection(2, 1, 3) == 255+	assert move_selection(0, -1, 3) == 056+	assert move_selection(0, 0, 0) == 057+	assert move_selection(5, 1, 0) == 058+	// G asks for a move past the end and lands on the last entry.59+	assert move_selection(3, -1, 3) == 260+}61+62+fn test_detail_lists_files_with_stats_and_notes_binaries() {63+	target := save('aaa', 'change two files', false)64+	flag := events.Flag{65+		save_id: 'aaa'66+		flagged: true67+		reason:  'sketchy'68+	}69+	deltas := [70+		snapshot.Delta{71+			path:    'src/main.v'72+			kind:    'modified'73+			added:   474+			removed: 275+		},76+		snapshot.Delta{77+			path:   'logo.png'78+			kind:   'added'79+			binary: true80+		},81+	]82+	text := detail_lines(target, deltas, flag).join('\n')83+	assert text.contains('checksum  aaa')84+	assert text.contains('flag      sketchy')85+	assert text.contains('src/main.v')86+	assert text.contains('+4')87+	assert text.contains('-2')88+	assert text.contains('logo.png')89+	assert text.contains('(binary)')90+	assert text.contains('2 files changed against the parent')91+}92+93+fn test_detail_says_so_when_nothing_changed() {94+	target := save('aaa', 'first save', false)95+	text := detail_lines(target, [], events.Flag{96+		save_id: 'aaa'97+	}).join('\n')98+	assert text.contains('nothing changed against the parent')99+}100+101+fn test_frame_shows_counts_the_selected_entry_and_the_status_bar() {102+	on_the_line := line_map(['aaa', 'ccc'])103+	items := [save('aaa', 'first', false), save('ccc', 'before storage', true)]104+	screen := Screen{105+		tab:      .all106+		items:    items107+		selected: 1108+		on_line:  on_the_line109+		counts:   count_tabs(items, on_the_line)110+		rows:     24111+		cols:     100112+		status:   '3 shown'113+	}114+	text := frame_lines(screen).join('\n')115+	assert text.contains('all (2)')116+	assert text.contains('checkpoints (1)')117+	assert text.contains('before storage')118+	assert text.contains('aaa')119+	// The selected entry carries its extra line.120+	assert text.contains('agent')121+	assert text.contains('◐')122+	assert text.contains('3 shown')123+}124+125+fn test_frame_says_no_results_when_the_search_matches_nothing() {126+	screen := Screen{127+		tab:    .all128+		rows:   24129+		cols:   80130+		query:  'nothing matches this'131+		counts: TabCounts{}132+	}133+	text := frame_lines(screen).join('\n')134+	assert text.contains('no results')135+	assert text.contains('/nothing matches this')136+}137+138+fn test_render_clears_each_line_before_drawing_it() {139+	screen := Screen{140+		tab:  .saves141+		rows: 6142+		cols: 60143+	}144+	frame := render(screen)145+	assert frame.starts_with('\x1b[H')146+	// One line-clear per row drawn, so a shorter frame cannot leave the tail of147+	// a longer one behind.148+	assert frame.count('\x1b[2K') == frame_lines(screen).len149+}

view file

tests/cli_test.v+243 −0
@@ -0,0 +1,243 @@1+module tests2+3+import os4+import time5+6+const root = @VMODROOT7+const binary = os.join_path(root, 'build', 'koh3')8+9+struct Result {10+mut:11+	code int12+	out  string13+	err  string14+}15+16+fn testsuite_begin() {17+	os.mkdir_all(os.join_path(root, 'build')) or { panic(err) }18+	build := os.execute('v -o ${q(binary)} ${q(os.join_path(root, 'src', 'koh3.v'))}')19+	if build.exit_code != 0 {20+		panic('could not build koh3: ${build.output}')21+	}22+}23+24+fn q(s string) string {25+	return "'${s.replace("'", "'\\''")}'"26+}27+28+fn temp_repo(name string) string {29+	dir := os.join_path(os.temp_dir(), 'koh3-cli-${name}-${time.now().unix_nano()}')30+	os.mkdir_all(dir) or { panic(err) }31+	return dir32+}33+34+fn run(repo string, args ...string) Result {35+	stamp := '${os.getpid()}-${time.now().unix_nano()}'36+	base := os.join_path(os.temp_dir(), 'koh3-cli-${stamp}')37+	out_path := '${base}.out'38+	err_path := '${base}.err'39+	code_path := '${base}.code'40+	mut command := '${q(binary)}'41+	for arg in args {42+		command += ' ${q(arg)}'43+	}44+	shell := 'cd ${q(repo)} && ${command} </dev/null >${q(out_path)} 2>${q(err_path)}; printf "%s" $? >${q(code_path)}'45+	result := os.execute(shell)46+	if result.exit_code != 0 {47+		panic('could not run test command: ${result.output}')48+	}49+	code := os.read_file(code_path) or { panic(err) }50+	out := os.read_file(out_path) or { '' }51+	err := os.read_file(err_path) or { '' }52+	os.rm(out_path) or {}53+	os.rm(err_path) or {}54+	os.rm(code_path) or {}55+	return Result{56+		code: code.int()57+		out:  out58+		err:  err59+	}60+}61+62+fn init_repo(repo string) {63+	r := run(repo, 'init')64+	assert r.code == 065+	assert os.exists(os.join_path(repo, '.koh'))66+	assert os.exists(os.join_path(repo, '.kohignore'))67+}68+69+fn write(repo string, path string, text string) {70+	full := os.join_path(repo, path)71+	os.mkdir_all(os.dir(full)) or { panic(err) }72+	os.write_file(full, text) or { panic(err) }73+}74+75+fn read_json_has(text string, key string) {76+	if !text.contains(key) {77+		panic('missing JSON key ${key}: ${text}')78+	}79+}80+81+fn test_init_and_usage() {82+	dir := temp_repo('init')83+	defer { os.rmdir_all(dir) or {} }84+	r := run(dir, 'init', '--json')85+	assert r.code == 086+	read_json_has(r.out, 'repo')87+	read_json_has(r.out, 'ignore_created')88+	assert run(dir, 'init').code == 189+	assert run(dir, 'unknown-command').code == 290+	assert run(dir, 'unknown-command').err.contains('unknown command')91+}92+93+fn test_save_checkpoint_and_status() {94+	dir := temp_repo('save')95+	defer { os.rmdir_all(dir) or {} }96+	init_repo(dir)97+	write(dir, 'tracked.txt', 'one\n')98+	r := run(dir, 'save', 'first message', '--agent', 'agent-7', '--session', 'session-a', '--json')99+	assert r.code == 0100+	for key in ['id', 'parent', 'message', 'created_at', 'machine', 'is_checkpoint', 'agent',101+		'session', 'files', 'size_bytes'] {102+		read_json_has(r.out, key)103+	}104+	assert r.out.contains('"message":"first message"')105+	assert r.out.contains('"agent":"agent-7"')106+	assert r.out.contains('"session":"session-a"')107+	assert r.out.contains('"is_checkpoint":false')108+	assert run(dir, 'save', 'again').code == 1109+	assert run(dir, 'save', 'again').err.contains('nothing has changed since the last save')110+	assert run(dir, 'save').code == 2111+	assert run(dir, 'save', 'x', '--bogus').code == 2112+	cp := run(dir, 'checkpoint', 'still first', '--json')113+	assert cp.code == 0114+	assert cp.out.contains('"is_checkpoint":true')115+	assert cp.out.contains('"parent":')116+	clean := run(dir, 'status')117+	assert clean.code == 0118+	assert clean.out.contains('no changes')119+	write(dir, 'tracked.txt', 'two\n')120+	write(dir, 'new.txt', 'new\n')121+	os.rm(os.join_path(dir, 'tracked.txt')) or { panic(err) }122+	write(dir, '.kohignore', '*.log\n')123+	status := run(dir, 'status')124+	assert status.code == 0125+	assert status.out.contains('+')126+	assert status.out.contains('~') || status.out.contains('-')127+	assert status.out.contains('added')128+	js := run(dir, 'status', '--json')129+	assert js.code == 0130+	for key in ['head_id', 'head_msg', 'dirty', 'modified', 'added', 'deleted', 'ignore_changed'] {131+		read_json_has(js.out, key)132+	}133+	assert js.out.contains('"ignore_changed":true')134+}135+136+fn test_status_requires_repo_and_ignore_rules() {137+	dir := temp_repo('status')138+	defer { os.rmdir_all(dir) or {} }139+	assert run(dir, 'status').code == 1140+	init_repo(dir)141+	write(dir, '.kohignore', '*.log\nbuild/\n')142+	write(dir, 'keep.txt', 'keep\n')143+	write(dir, 'ignored.log', 'ignore\n')144+	write(dir, 'build/output.txt', 'ignore\n')145+	assert run(dir, 'save', 'keep').code == 0146+	assert run(dir, 'status').out.contains('no changes')147+	assert !run(dir, 'status').out.contains('ignored.log')148+	assert !run(dir, 'status').out.contains('build/output.txt')149+	assert !run(dir, 'status').out.contains('LOG.md')150+}151+152+fn test_log_and_diff() {153+	dir := temp_repo('history')154+	defer { os.rmdir_all(dir) or {} }155+	init_repo(dir)156+	write(dir, 'a.txt', 'one\n')157+	first := run(dir, 'save', 'one', '--session', 'alpha', '--json')158+	id1 := first.out.all_after('"id":"')[..12]159+	write(dir, 'a.txt', 'two\n')160+	second := run(dir, 'save', 'two', '--session', 'beta', '--json')161+	id2 := second.out.all_after('"id":"')[..12]162+	cp := run(dir, 'checkpoint', 'checkpoint', '--json')163+	assert cp.code == 0164+	log := run(dir, 'log')165+	assert log.code == 0166+	assert log.out.contains('▸')167+	assert log.out.contains('◐')168+	lj := run(dir, 'log', '--json')169+	assert lj.code == 0170+	assert lj.out.contains('"head_id"')171+	assert lj.out.contains('"saves"')172+	assert run(dir, 'log', '--since', 'today').out.contains('two')173+	session := run(dir, 'log', '--session', 'alpha')174+	assert session.out.contains('one')175+	assert !session.out.contains('two')176+	write(dir, 'a.txt', 'three\n')177+	d := run(dir, 'diff')178+	assert d.code == 0179+	assert d.out.contains('--- a/')180+	assert d.out.contains('+++ b/')181+	assert run(dir, 'diff', id1).code == 0182+	assert run(dir, 'diff', id1, id2).code == 0183+	dj := run(dir, 'diff', '--json')184+	assert dj.code == 0185+	assert dj.out.contains('"from"')186+	assert dj.out.contains('"to"')187+	assert dj.out.contains('"files"')188+	assert dj.out.contains('"change":"modified"')189+	assert run(dir, 'diff', 'not-a-real-id').code == 1190+	assert run(dir, 'diff', id1, id2, 'third').code == 1191+	write(dir, 'blob.bin', '\x00\x01\x02')192+	diff_binary := run(dir, 'diff', '--json')193+	assert diff_binary.out.contains('"binary":true')194+	assert diff_binary.out.contains('Binary files differ')195+}196+197+fn test_undo_revert_and_load() {198+	dir := temp_repo('restore')199+	defer { os.rmdir_all(dir) or {} }200+	init_repo(dir)201+	write(dir, 'a.txt', 'one\n')202+	one := run(dir, 'save', 'one', '--json').out203+	id1 := one.all_after('"id":"')[..12]204+	write(dir, 'a.txt', 'two\n')205+	write(dir, 'gone.txt', 'gone\n')206+	second := run(dir, 'save', 'two', '--json').out207+	id2 := second.all_after('"id":"')[..12]208+	write(dir, 'a.txt', 'three\n')209+	third := run(dir, 'save', 'three', '--json').out210+	id3 := third.all_after('"id":"')[..12]211+	write(dir, 'never.txt', 'keep me\n')212+	undo := run(dir, 'undo', '--yes', '--json')213+	assert undo.code == 0214+	if !undo.out.contains(id2) {215+		panic('undo JSON did not name ${id2}: ${undo.out}')216+	}217+	assert os.read_file(os.join_path(dir, 'a.txt'))! == 'two\n'218+	assert os.exists(os.join_path(dir, 'gone.txt'))219+	assert os.exists(os.join_path(dir, 'never.txt'))220+	assert run(dir, 'load', id3, '--yes').code == 0221+	assert os.read_file(os.join_path(dir, 'a.txt'))! == 'three\n'222+	assert run(dir, 'undo', '2', '--yes').code == 0223+	assert run(dir, 'load', id2, '--yes').code == 0224+	assert run(dir, 'undo', '99', '--yes').err.contains('nothing before')225+	write(dir, 'a.txt', 'dirty\n')226+	before := run(dir, 'log')227+	refuse := run(dir, 'revert')228+	assert refuse.code == 1229+	assert refuse.err.contains('needs --yes when not interactive')230+	assert run(dir, 'log').out == before.out231+	assert os.read_file(os.join_path(dir, 'a.txt'))! == 'dirty\n'232+	write(dir, 'untracked.txt', 'remove\n')233+	rv := run(dir, 'revert', '--yes')234+	assert rv.code == 0235+	assert os.read_file(os.join_path(dir, 'a.txt'))! == 'two\n'236+	assert !os.exists(os.join_path(dir, 'untracked.txt'))237+	assert run(dir, 'revert', '--yes').out.contains('working tree already matches')238+	assert run(dir, 'load', '--checkpoint', '--yes').code == 0239+	assert run(dir, 'load', id2, '--json').code == 1240+	assert run(dir, 'load', id2, '--json').err.contains('error')241+	assert run(dir, 'load').code == 2242+	assert run(dir, 'load', 'unknown-id', '--yes').code == 1243+}

view file

vendor/shim/koh_term.c+102 −0
@@ -0,0 +1,102 @@1+#include "koh_term.h"2+3+#include <poll.h>4+#include <stdio.h>5+#include <sys/ioctl.h>6+#include <termios.h>7+#include <unistd.h>8+9+static struct termios koh_saved;10+static int koh_have_saved = 0;11+12+int koh_term_raw(int enable) {13+	if (enable) {14+		if (!isatty(STDIN_FILENO)) {15+			return 0;16+		}17+		if (tcgetattr(STDIN_FILENO, &koh_saved) != 0) {18+			return 0;19+		}20+		koh_have_saved = 1;21+		struct termios raw = koh_saved;22+		/* No line buffering, no echo, no flow control, no signal keys: the23+		 * view wants the bytes and has to be able to clean up after itself. */24+		raw.c_lflag &= (tcflag_t) ~(ICANON | ECHO | ISIG | IEXTEN);25+		raw.c_iflag &= (tcflag_t) ~(IXON | ICRNL | BRKINT | INPCK | ISTRIP);26+		raw.c_cc[VMIN] = 1;27+		raw.c_cc[VTIME] = 0;28+		return tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == 0;29+	}30+	if (koh_have_saved) {31+		tcsetattr(STDIN_FILENO, TCSAFLUSH, &koh_saved);32+		koh_have_saved = 0;33+	}34+	return 1;35+}36+37+int koh_term_size(int *rows, int *cols) {38+	struct winsize size;39+	if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &size) == 0 && size.ws_row > 0 && size.ws_col > 0) {40+		*rows = (int)size.ws_row;41+		*cols = (int)size.ws_col;42+		return 1;43+	}44+	*rows = 24;45+	*cols = 80;46+	return 0;47+}48+49+static int koh_poll_input(int milliseconds) {50+	struct pollfd p;51+	p.fd = STDIN_FILENO;52+	p.events = POLLIN;53+	p.revents = 0;54+	return poll(&p, 1, milliseconds) > 0;55+}56+57+int koh_read_key(char *buf, int cap) {58+	if (cap <= 0) {59+		return 0;60+	}61+	ssize_t n = read(STDIN_FILENO, buf, 1);62+	if (n <= 0) {63+		return 0;64+	}65+	int total = 1;66+	/* An arrow key arrives as ESC [ A, usually in one burst but not always,67+	 * so give the rest of the sequence a moment to show up. */68+	if (buf[0] == 0x1b) {69+		if (koh_poll_input(25)) {70+			ssize_t m = read(STDIN_FILENO, buf + 1, (size_t)(cap - 1));71+			if (m > 0) {72+				total += (int)m;73+			}74+		}75+		return total;76+	}77+	/* Otherwise deliver exactly one character: the continuation bytes of a78+	 * multi-byte character are gathered, but nothing else is. Two keys typed79+	 * quickly, or pasted, therefore arrive as two keystrokes rather than one80+	 * blob that no single key can be matched against. */81+	int remaining = 0;82+	unsigned char first = (unsigned char)buf[0];83+	if ((first & 0xf8) == 0xf0) {84+		remaining = 3;85+	} else if ((first & 0xf0) == 0xe0) {86+		remaining = 2;87+	} else if ((first & 0xe0) == 0xc0) {88+		remaining = 1;89+	}90+	while (remaining > 0 && total < cap) {91+		if (!koh_poll_input(10)) {92+			break;93+		}94+		ssize_t m = read(STDIN_FILENO, buf + total, 1);95+		if (m <= 0) {96+			break;97+		}98+		total += (int)m;99+		remaining--;100+	}101+	return total;102+}

view file

vendor/shim/koh_term.h+25 −0
@@ -0,0 +1,25 @@1+/* Terminal control for the history view.2+ *3+ * The view needs three things V's standard library does not expose directly:4+ * raw mode with the terminal restored on exit, the window size, and one5+ * keystroke at a time with escape sequences kept together. Keeping this in C6+ * means one implementation for macOS and Linux, and no termios struct layout7+ * in V.8+ */9+10+#ifndef KOH_TERM_H11+#define KOH_TERM_H12+13+/* Switches stdin to raw mode (enable = 1) or back (enable = 0). Returns 1 on14+ * success. Ctrl-C is delivered as a key rather than a signal, so the caller15+ * can restore the terminal before quitting. */16+int koh_term_raw(int enable);17+18+/* Fills rows and columns; falls back to 24x80 when the size is unavailable. */19+int koh_term_size(int *rows, int *cols);20+21+/* Reads one keystroke into buf, gathering the rest of an escape sequence when22+ * the first byte is ESC. Returns the byte count, or 0 at end of input. */23+int koh_read_key(char *buf, int cap);24+25+#endif

view file