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
↓ koh-942d08c7.tar.gzatlas at this save
files changed26 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 −0src/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+}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+}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]}"')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 {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 }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+}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+}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 }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+}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+}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 }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+}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-}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 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+}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+}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+}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 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) {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+}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+}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+}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+}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+}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+}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