752b815dd4f5
rebuild the history view on Koh 2's layout: evenly spaced entries that never wrap, HEAD marked and every other save a dot, the selected ID gold and the rest greyed, search only after /, q quits while searching, and flag or load only after the strict confirm (3.0.65)
- time
- 2026-09-26 13:09 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- history view
- parent
- a98160f0b963
↓ koh-752b815d.tar.gzatlas at this save
files changed5 files
VERSION+1 −1src/fmt/fmt.v+48 −0src/view/view.v+340 −268src/view/view_test.v+192 −74v.mod+1 −1src/fmt/fmt.v+48 −0
@@ -50,6 +50,20 @@5050 return paint(text, '38;5;245')5151 }5252 53+// The history view keeps Koh 2's softer palette: gold for the selected save,54+// muted grey for everything around it, blue for HEAD.55+pub fn gold(text string) string {56+ return paint(text, '38;5;179')57+}58+59+pub fn muted(text string) string {60+ return paint(text, '38;5;241')61+}62+63+pub fn blue(text string) string {64+ return paint(text, '38;5;110')65+}66+5367 // strip_ansi removes the colour codes, for measuring how wide a line really is.5468 pub fn strip_ansi(text string) string {5569 mut out := ''@@ -74,12 +88,46 @@7488 return strip_ansi(text).runes().len7589 }7690 91+// fit cuts a line to `width` visible columns and keeps its colour codes, so a92+// long line can never wrap and scroll a full-screen frame.93+pub fn fit(text string, width int) string {94+ if visible_width(text) <= width {95+ return text96+ }97+ mut out := []rune{}98+ mut shown := 099+ mut skipping := false100+ for c in text.runes() {101+ if c == `\x1b` {102+ skipping = true103+ }104+ if skipping {105+ out << c106+ if (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) {107+ skipping = false108+ }109+ continue110+ }111+ if shown == width {112+ break113+ }114+ out << c115+ shown++116+ }117+ cut := out.string()118+ return if cut.contains('\x1b') { cut + '\x1b[0m' } else { cut }119+}120+77121 pub fn stamp(unix_seconds i64) string {78122 return time.unix(unix_seconds).local().format()79123 }80124 81125 pub fn date_of(unix_seconds i64) string {82126 return time.unix(unix_seconds).local().ymmdd()127+}128+129+pub fn hour_of(unix_seconds i64) string {130+ return time.unix(unix_seconds).local().hhmm()83131 }84132 85133 pub fn clock_of(unix_seconds i64) string {src/view/view.v+340 −268
@@ -1,13 +1,14 @@11 //! The history view: the saves and checkpoints list, full screen.22 //!33 //! 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.4+//! `koh checkpoints` open it on their tab. The look and the keys are Koh 2's5+//! (`src/commands/history.zig`): a tab bar, evenly spaced entries between6+//! separators, and a status bar with the hints for the current mode.77 //!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.8+//! The view only reads — history, flags and diffs — and hands any decision back9+//! to the caller as an `Action`, so the command layer stays the only thing that10+//! changes anything. `frame_lines` turns a `Session` into the rows of one frame,11+//! which keeps the interesting part testable without a terminal.1112 1213 module view1314 @@ -49,7 +50,7 @@4950 checkpoints int5051 }5152 52-// Screen is one frame's worth of state.53+// Screen is what the list shows.5354 pub struct Screen {5455 pub mut:5556 tab Tab@@ -58,6 +59,7 @@5859 selected int5960 query string6061 status string62+ head string6163 flags map[string]events.Flag6264 on_line map[string]bool6365 rows int@@ -67,28 +69,30 @@6769 enum Mode {6870 list6971 search70- reason7172 detail73+ reason74+ confirm7275 }7376 7477 // Session is everything the event loop changes: what is on screen, which mode7578 // it is in, and what the current action is about.7679 struct Session {7780 mut:78- screen Screen79- mode Mode80- target history.Save81- typed string81+ screen Screen82+ mode Mode83+ target history.Save84+ typed string85+ pending Action86+ detail []string8287 // set when an action changed history, so the lists are read again8388 stale bool84- // the detail's diff, worked out once per save rather than per key85- deltas []snapshot.Delta86- deltas_for string89+ // the detail is worked out once per save rather than per key90+ detail_for string8791 }8892 89-// run shows the view until the user leaves it. `act` performs a chosen action90-// and returns the message for the status bar, so the view itself never changes91-// history.93+// run shows the view until the user leaves it. `act` performs a confirmed94+// action and returns the message for the status bar, so the view itself never95+// changes history.9296 pub fn run(store &storage.Store, start Tab, act fn (store &storage.Store, action Action) string) ! {9397 enter_screen()9498 defer {@@ -105,72 +109,61 @@105109 if session.stale {106110 // A load appends a save and a flag changes a mark: read both again.107111 every = history.all_saves(store)!112+ session.screen.head = history.head_id(store)108113 session.screen.on_line = on_line_set(store)109114 session.screen.flags = events.latest_flags(store)!110115 session.screen.counts = count_tabs(every, session.screen.on_line)116+ session.detail_for = ''111117 session.stale = false112118 }113119 session.screen.rows, session.screen.cols = terminal_size()114120 session.screen.items = filter_saves(every, session.screen.tab, session.screen.on_line,115121 session.screen.query)116- if session.screen.selected >= session.screen.items.len {117- session.screen.selected = move_selection(session.screen.selected, 0,118- session.screen.items.len)122+ session.screen.selected = move_selection(session.screen.selected, 0, session.screen.items.len)123+ if session.mode in [.detail, .reason, .confirm] && session.detail_for != session.target.id {124+ session.detail = detail_lines(session.target, session.screen.head, diff_of(store,125+ session.target), session.screen.flags[session.target.id] or { events.Flag{} })126+ session.detail_for = session.target.id119127 }120- paint(frame_for(store, mut session))128+ paint(render(frame_lines(session)))129+ // A status message is shown for one frame, as in Koh 2.130+ session.screen.status = ''121131 key := read_key()122132 if key.kind in [.interrupt, .eof] {123133 return124134 }125- leaving := match session.mode {126- .list { list_key(mut session, key) }127- .search { search_key(mut session, key) }128- .reason { reason_key(mut session, store, key, act) }129- .detail { detail_key(mut session, store, key, act) }130- }131-132- if leaving {135+ if handle_key(mut session, store, key, act) {133136 return134137 }135138 }136139 }137140 138-fn frame_for(store &storage.Store, mut session Session) string {139- match session.mode {140- .detail, .reason {141- // Typing a flag reason repaints on every key; diffing a large save142- // each time would stall the typing.143- if session.deltas_for != session.target.id {144- session.deltas = snapshot.diff_trees(store, parent_tree(store, session.target), child_tree(store,145- session.target)) or { []snapshot.Delta{} }146- session.deltas_for = session.target.id147- }148- return detail_frame(session.screen, session.target, session.deltas,149- session.mode == .reason, session.typed)150- }151- else {152- return render(session.screen)153- }141+// handle_key applies one key and reports whether to leave the view.142+fn handle_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {143+ return match session.mode {144+ .list { list_key(mut session, key) }145+ .search { search_key(mut session, key) }146+ .detail { detail_key(mut session, key) }147+ .reason { reason_key(mut session, key) }148+ .confirm { confirm_key(mut session, store, key, act) }154149 }155150 }156151 157-// list_key handles one key in the list and reports whether to leave the view.158152 fn list_key(mut session Session, key Key) bool {153+ count := session.screen.items.len159154 match key.kind {160155 .tab {161156 session.screen.tab = next_tab(session.screen.tab)162157 session.screen.selected = 0163158 }164159 .down {165- session.screen.selected = move_selection(session.screen.selected, 1,166- session.screen.items.len)160+ session.screen.selected = move_selection(session.screen.selected, 1, count)167161 }168162 .up {169- session.screen.selected = move_selection(session.screen.selected, -1,170- session.screen.items.len)163+ session.screen.selected = move_selection(session.screen.selected, -1, count)171164 }172165 .enter {173- if session.screen.items.len > 0 {166+ if count > 0 {174167 session.target = session.screen.items[session.screen.selected]175168 session.mode = .detail176169 }@@ -181,29 +174,26 @@181174 return true182175 }183176 'j' {184- session.screen.selected = move_selection(session.screen.selected, 1,185- session.screen.items.len)177+ session.screen.selected = move_selection(session.screen.selected, 1, count)186178 }187179 'k' {188180 session.screen.selected = move_selection(session.screen.selected, -1,189- session.screen.items.len)181+ count)190182 }183+ // The list is newest first: g is the oldest save, G the newest.191184 'g' {192- session.screen.selected = 0185+ session.screen.selected = move_selection(count, -1, count)193186 }194187 'G' {195- session.screen.selected = move_selection(session.screen.items.len, -1,196- session.screen.items.len)188+ session.screen.selected = 0197189 }198190 '/' {199191 session.mode = .search200- session.screen.query = ''201192 }202193 'c' {203- if session.screen.items.len > 0 {204- id := session.screen.items[session.screen.selected].id205- copy_id(id)206- session.screen.status = 'copied ${id}'194+ if count > 0 {195+ copy_selected(mut session.screen,196+ session.screen.items[session.screen.selected].id)207197 }208198 }209199 else {}@@ -211,20 +201,27 @@211201 }212202 else {}213203 }214-215204 return false216205 }217206 207+// search_key edits the query. q still quits, as in Koh 2: a view you cannot208+// leave is worse than a search that cannot contain q.218209 fn search_key(mut session Session, key Key) bool {219210 match key.kind {220211 .rune {212+ if key.text == 'q' {213+ return true214+ }221215 session.screen.query += key.text216+ session.screen.selected = 0222217 }223218 .backspace {224219 session.screen.query = trim_last(session.screen.query)220+ session.screen.selected = 0225221 }226222 .escape {227223 session.screen.query = ''224+ session.screen.selected = 0228225 session.mode = .list229226 }230227 .enter {@@ -232,11 +229,52 @@232229 }233230 else {}234231 }232+ return false233+}235234 235+fn detail_key(mut session Session, key Key) bool {236+ match key.kind {237+ .escape {238+ session.mode = .list239+ }240+ .rune {241+ match key.text {242+ 'q' {243+ return true244+ }245+ 'l' {246+ session.pending = Action{247+ kind: .load248+ save_id: session.target.id249+ }250+ session.mode = .confirm251+ }252+ 'f' {253+ if is_flagged(session.screen, session.target.id) {254+ session.pending = Action{255+ kind: .clear_flag256+ save_id: session.target.id257+ }258+ session.mode = .confirm259+ } else {260+ session.typed = ''261+ session.mode = .reason262+ }263+ }264+ 'c' {265+ copy_selected(mut session.screen, session.target.id)266+ }267+ else {}268+ }269+ }270+ else {}271+ }236272 return false237273 }238274 239-fn reason_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {275+// reason_key collects a flag's reason. Every key is text here, q included:276+// a reason may well say "quick hack".277+fn reason_key(mut session Session, key Key) bool {240278 match key.kind {241279 .rune {242280 session.typed += key.text@@ -246,67 +284,32 @@246284 }247285 .escape {248286 session.typed = ''249- session.mode = .list287+ session.mode = .detail250288 }251289 .enter {252290 if session.typed != '' {253- session.stale = true254- session.screen.status = act(store, Action{291+ session.pending = Action{255292 kind: .flag256293 save_id: session.target.id257294 reason: session.typed258- })295+ }296+ session.typed = ''297+ session.mode = .confirm259298 }260- session.typed = ''261- session.mode = .list262299 }263300 else {}264301 }265-266302 return false267303 }268304 269-fn detail_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {270- match key.kind {271- .escape {272- session.mode = .list273- }274- .rune {275- match key.text {276- 'l' {277- if ask_confirm('load [${session.target.id}]') {278- session.stale = true279- session.screen.status = act(store, Action{280- kind: .load281- save_id: session.target.id282- })283- }284- session.mode = .list285- }286- 'f' {287- state := session.screen.flags[session.target.id] or {288- events.Flag{289- save_id: session.target.id290- }291- }292- if state.flagged {293- session.stale = true294- session.screen.status = act(store, Action{295- kind: .clear_flag296- save_id: session.target.id297- })298- session.mode = .list299- } else {300- session.typed = ''301- session.mode = .reason302- }303- }304- else {}305- }306- }307- else {}305+// confirm_key is the strict confirm: only a lowercase y goes ahead.306+fn confirm_key(mut session Session, store &storage.Store, key Key, act fn (store &storage.Store, action Action) string) bool {307+ if key.kind == .rune && key.text == 'y' {308+ session.screen.status = act(store, session.pending)309+ session.stale = true308310 }309-311+ session.pending = Action{}312+ session.mode = .list310313 return false311314 }312315 @@ -359,217 +362,290 @@359362 return moved360363 }361364 362-// detail_lines describes one save: what it was, and what it changed.363-pub fn detail_lines(save history.Save, deltas []snapshot.Delta, flag events.Flag) []string {364- mut lines := [365- ' ${fmt.bold('[' + save.id + ']')} ${save.message}',366- '',367- ' time ${fmt.stamp(save.created_at)}',368- ' size ${fmt.human_bytes(save.size_bytes)}',369- ' agent ${or_dash(save.agent)}',370- ' session ${or_dash(save.session)}',371- ' parent ${or_dash(save.parent)}',372- ' checksum ${save.id}',373- ]374- if flag.flagged {375- lines << ' flag ${or_dash(flag.reason)}'365+// render writes a frame over the last one: each row is cleared before it is366+// drawn, and the last row has no newline, so the terminal never scrolls.367+pub fn render(lines []string) string {368+ mut out := strings.new_builder(4096)369+ out.write_string('\x1b[H')370+ for i, line in lines {371+ out.write_string('\x1b[2K${line}')372+ if i < lines.len - 1 {373+ out.write_string('\r\n')374+ }376375 }377- lines << ''378- if deltas.len == 0 {379- lines << ' ${fmt.dim('nothing changed against the parent')}'380- return lines381- }382- lines << ' ${fmt.plural(deltas.len, 'file')} changed against the parent:'383- for delta in deltas {384- lines << ' ${change_mark(delta)} ${delta.path}${line_stats(delta)}'385- }386- return lines376+ return out.str()387377 }388378 389-fn line_stats(delta snapshot.Delta) string {390- if delta.binary {391- return ' ${fmt.dim('(binary)')}'379+// frame_lines is one whole frame, exactly as tall as the screen and no line380+// wider than it: the tab bar, a gap, the body, the status bar.381+fn frame_lines(session Session) []string {382+ screen := session.screen383+ rows := if screen.rows > 4 { screen.rows } else { 4 }384+ cols := if screen.cols > 10 { screen.cols } else { 10 }385+ body := rows - 3386+ mut lines := [tab_bar(screen, session.mode), '']387+ if session.mode in [.detail, .reason, .confirm] {388+ lines << session.detail#[..body]389+ } else {390+ lines << list_body(screen, body, cols)392391 }393- return ' ${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'392+ for lines.len < rows - 1 {393+ lines << ''394+ }395+ lines << status_bar(session, cols)396+ return lines.map(fmt.fit(it, cols))394397 }395398 396-fn change_mark(delta snapshot.Delta) string {397- return match delta.kind {398- 'added' { fmt.green('+') }399- 'removed' { fmt.red('-') }400- else { fmt.amber('~') }399+// list_body lays the entries out in up to six equal blocks, each a header, a400+// message, the selected entry's details and a separator, with the spare rows401+// split above and below so the list spreads over the whole screen.402+fn list_body(screen Screen, body int, cols int) []string {403+ if screen.items.len == 0 {404+ return [' ${fmt.muted('no results')}']401405 }406+ slots := if body / 4 < 6 { body / 4 } else { 6 }407+ if slots == 0 {408+ return [entry_header(screen.items[screen.selected], true, screen)]409+ }410+ separator := fmt.muted('─'.repeat(cols))411+ start := window_start(screen.selected, slots, screen.items.len)412+ mut lines := []string{}413+ for slot in 0 .. slots {414+ height := body / slots + if slot < body % slots { 1 } else { 0 }415+ spare := if height > 4 { height - 4 } else { 0 }416+ for _ in 0 .. spare / 2 {417+ lines << ''418+ }419+ index := start + slot420+ if index < screen.items.len {421+ entry := screen.items[index]422+ selected := index == screen.selected423+ lines << entry_header(entry, selected, screen)424+ lines << entry_message(entry, selected, screen)425+ lines << if selected { entry_meta(entry) } else { '' }426+ } else {427+ lines << ['', '', '']428+ }429+ lines << separator430+ for _ in 0 .. spare - spare / 2 {431+ lines << ''432+ }433+ }434+ return lines402435 }403436 404-// render is a whole frame of the list: tabs, entries, status bar.405-pub fn render(screen Screen) string {406- mut out := strings.Builder{}407- out.write_string('\x1b[H')408- for line in frame_lines(screen) {409- out.write_string('\x1b[2K${line}\r\n')437+// window_start keeps the selected entry near the middle of the visible blocks.438+fn window_start(selected int, slots int, count int) int {439+ if count <= slots {440+ return 0410441 }411- return out.str()442+ mut start := if selected > slots / 2 { selected - slots / 2 } else { 0 }443+ if start + slots > count {444+ start = count - slots445+ }446+ return start412447 }413448 414-pub fn frame_lines(screen Screen) []string {415- mut lines := [tab_bar(screen)]416- mut body := []string{}417- for i, save in screen.items {418- body << entry_line(save, i == screen.selected, screen)419- if i == screen.selected {420- body << meta_line(save)421- }422- body << fmt.dim(' ${'─'.repeat(separator_width(screen.cols))}')449+// entry_header is the mark, ID, time and date. HEAD has its own mark; every450+// other save is a dot. The selected save's ID is gold and everything else is451+// greyed out around it.452+fn entry_header(save history.Save, selected bool, screen Screen) string {453+ head := save.id == screen.head454+ flagged := is_flagged(screen, save.id)455+ mark := if head { fmt.blue('▸') } else { fmt.muted('·') }456+ mut line := ' ${mark} '457+ line += if flagged {458+ fmt.red(save.id)459+ } else if selected {460+ fmt.gold(save.id)461+ } else {462+ fmt.muted(save.id)423463 }424- if body.len == 0 {425- body << fmt.dim(' no results')464+ if head {465+ line += ' ${fmt.blue('HEAD')}'426466 }427- room := body_room(screen.rows)428- start := if screen.selected >= room { screen.selected - room + 1 } else { 0 }429- for i := start; i < body.len && lines.len < room + 1; i++ {430- lines << body[i]467+ mut labels := []string{}468+ if selected && save.is_checkpoint {469+ labels << '[checkpoint]'431470 }432- for lines.len < room + 1 {433- lines << ''471+ if flagged {472+ labels << '[flagged]'434473 }435- lines << status_bar(screen)436- return lines474+ if save.id !in screen.on_line {475+ labels << '[replayed away]'476+ }477+ if labels.len > 0 {478+ line += ' ${fmt.muted(labels.join(' '))}'479+ }480+ stamp := '${fmt.hour_of(save.created_at)} ${fmt.date_of(save.created_at)}'481+ line += ' ${if selected { stamp } else { fmt.muted(stamp) }}'482+ return line437483 }438484 439-fn body_room(rows int) int {440- // The tab bar and the status bar take one row each; two more rows keep the441- // list off the very bottom of the screen.442- if rows <= 4 {443- return 1444- }445- return rows - 3485+fn entry_message(save history.Save, selected bool, screen Screen) string {486+ warning := if is_flagged(screen, save.id) { fmt.red('⚠ ') } else { '' }487+ return ' ${warning}${if selected { save.message } else { fmt.muted(save.message) }}'446488 }447489 448-fn separator_width(cols int) int {449- if cols <= 6 {450- return 1451- }452- return cols - 4490+fn entry_meta(save history.Save) string {491+ agent := if save.agent == '' { 'unknown' } else { save.agent }492+ session := if save.session == '' { 'no session' } else { save.session }493+ return ' ${fmt.human_bytes(save.size_bytes)} · ${agent} · ${session}'453494 }454495 455-fn tab_bar(screen Screen) string {496+fn tab_bar(screen Screen, mode Mode) string {456497 names := ['all', 'saves', 'checkpoints']457498 counts := [screen.counts.all, screen.counts.saves, screen.counts.checkpoints]458- mut parts := []string{}499+ mut bar := ''459500 for i, name in names {460- label := '${name} (${counts[i]})'461501 if tab_at(i) == screen.tab {462- parts << fmt.bold('[${label}]')502+ bar += fmt.gold(' [${name} (${counts[i]})] ')463503 } else {464- parts << fmt.dim(label)504+ bar += fmt.muted(' ${name} (${counts[i]}) ')465505 }466506 }467- mut bar := ' ${parts.join(' ')} '468- if screen.query != '' {469- bar += fmt.bold('/${screen.query}_')507+ bar += fmt.muted('│ ')508+ if mode == .search {509+ bar += '/${screen.query}_'510+ } else if screen.query != '' {511+ bar += '/${screen.query}'470512 } else {471- bar += fmt.dim('/ search')513+ bar += fmt.muted('/ search')472514 }473515 return bar474516 }475517 476-fn entry_line(save history.Save, selected bool, screen Screen) string {477- mut line := ' ${if selected { fmt.amber('▸') } else { ' ' }} '478- line += '${save_mark(save, screen)} ${fmt.dim('[' + save.id + ']')} '479- line += '${fmt.date_of(save.created_at)} ${fmt.clock_of(save.created_at)} ${save.message}'480- mut tags := []string{}481- if save.agent != '' || save.session != '' {482- tags << '${or_dash(save.agent)} · ${or_dash(save.session)}'518+fn status_bar(session Session, cols int) string {519+ hint := match session.mode {520+ .search {521+ 'type to filter · enter keep · esc clear'522+ }523+ .detail {524+ flag_word := if is_flagged(session.screen, session.target.id) {525+ 'clear flag'526+ } else {527+ 'flag'528+ }529+ 'esc back · l load · f ${flag_word} · c copy · q'530+ }531+ .reason {532+ 'flag reason: ${session.typed}_ · enter confirm · esc cancel'533+ }534+ .confirm {535+ '${action_word(session.pending.kind)} [${session.pending.save_id}]? y yes · n/esc cancel'536+ }537+ .list {538+ '↑↓ nav · / search · tab mode · c copy · enter detail · q'539+ }483540 }484- if save.restores != '' {485- tags << 'back to ${save.restores}'541+ left := ' view: ${tab_name(session.screen.tab)} · ${hint}'542+ right := if session.screen.status != '' {543+ session.screen.status544+ } else if session.mode == .list || session.mode == .search {545+ '${session.screen.items.len} shown'546+ } else {547+ ''486548 }487- if save.id !in screen.on_line {488- tags << 'replayed away'549+ if right == '' {550+ return fmt.muted(left)489551 }490- if tags.len > 0 {491- line += ' ${fmt.dim(tags.join(' '))}'492- }493- if selected {494- return fmt.reverse(line)495- }496- return line552+ // The hints give way to the count or the result of an action, which is553+ // the one thing worth reading right after a key.554+ room := cols - fmt.visible_width(right) - 1555+ shown := fmt.fit(left, if room > 0 { room } else { 0 })556+ gap := cols - fmt.visible_width(shown) - fmt.visible_width(right)557+ return fmt.muted('${shown}${' '.repeat(if gap > 0 { gap } else { 0 })}${right}')497558 }498559 499-fn meta_line(save history.Save) string {500- return fmt.dim(' ${fmt.human_bytes(save.size_bytes)} · agent ${or_dash(save.agent)} · session ${or_dash(save.session)}')501-}502-503-fn status_bar(screen Screen) string {504- mut hints := 'j/k move · / search · tab mode · c copy · enter detail · q quit'505- if screen.status != '' {506- hints = screen.status560+fn action_word(kind ActionKind) string {561+ return match kind {562+ .load { 'load' }563+ .flag { 'flag' }564+ .clear_flag { 'clear the flag on' }565+ .none { '' }507566 }508- left := 'view: ${tab_name(screen.tab)} · ${hints}'509- right := '${screen.items.len} shown'510- mut gap := screen.cols - fmt.visible_width(left) - fmt.visible_width(right) - 3511- if gap < 1 {512- gap = 1513- }514- return fmt.dim(' ${left}${' '.repeat(gap)}${right}')515567 }516568 517-fn save_mark(save history.Save, screen Screen) string {518- // Replayed away: still in history, no longer on the line.519- if save.id !in screen.on_line {520- return fmt.dim('↪')569+// detail_lines describes one save: what it was, and what it changed.570+pub fn detail_lines(save history.Save, head string, deltas []snapshot.Delta, flag events.Flag) []string {571+ mut title := ' ${fmt.gold(save.id)}'572+ if save.id == head {573+ title += ' ${fmt.blue('HEAD')}'521574 }522575 if save.is_checkpoint {523- return fmt.amber('◐')576+ title += ' [checkpoint]'524577 }525- if (screen.flags[save.id] or {526- events.Flag{527- save_id: save.id528- }529- }).flagged {530- return fmt.red('⚑')578+ if flag.flagged {579+ title += ' [flagged]'531580 }532- return ' '581+ mut lines := [582+ title,583+ ' ${save.message}',584+ '',585+ detail_row('time', '${fmt.date_of(save.created_at)} · ${fmt.clock_of(save.created_at)}'),586+ detail_row('size', fmt.human_bytes(save.size_bytes)),587+ detail_row('agent', if save.agent == '' { 'unknown' } else { save.agent }),588+ detail_row('session', if save.session == '' { 'none' } else { save.session }),589+ detail_row('parent', if save.parent == '' { 'root' } else { save.parent }),590+ detail_row('checksum', save.id),591+ ]592+ if flag.flagged {593+ lines << detail_row('flag', flag.reason)594+ }595+ lines << ''596+ if save.parent == '' {597+ lines << ' initial save — no parent to diff against'598+ return lines599+ }600+ lines << ' ${deltas.len} files changed'601+ for delta in deltas {602+ lines << ' ${change_mark(delta)} ${delta.path} ${line_stats(delta)}'603+ }604+ return lines533605 }534606 535-fn detail_frame(screen Screen, target history.Save, deltas []snapshot.Delta, asking_reason bool, typed string) string {536- flag := screen.flags[target.id] or {537- events.Flag{538- save_id: target.id539- }607+fn detail_row(label string, value string) string {608+ return ' ${fmt.muted(label + ' '.repeat(9 - label.len))}${value}'609+}610+611+fn line_stats(delta snapshot.Delta) string {612+ if delta.binary {613+ return fmt.muted('(binary)')540614 }541- mut out := strings.Builder{}542- out.write_string('\x1b[H\x1b[2J')543- for line in detail_lines(target, deltas, flag) {544- out.write_string('${line}\r\n')615+ return '${fmt.green('+' + delta.added.str())} ${fmt.red('-' + delta.removed.str())}'616+}617+618+fn change_mark(delta snapshot.Delta) string {619+ return match delta.kind {620+ 'added' { fmt.green('A') }621+ 'removed' { fmt.red('R') }622+ else { fmt.gold('M') }545623 }546- out.write_string('\r\n')547- if asking_reason {548- out.write_string(' ${fmt.amber('flag reason')}: ${typed}_')549- return out.str()624+}625+626+fn is_flagged(screen Screen, save_id string) bool {627+ return (screen.flags[save_id] or { events.Flag{} }).flagged628+}629+630+fn diff_of(store &storage.Store, save history.Save) []snapshot.Delta {631+ return snapshot.diff_trees(store, parent_tree(store, save), child_tree(store, save)) or {632+ []snapshot.Delta{}550633 }551- action := if flag.flagged { 'clear flag' } else { 'flag' }552- out.write_string(fmt.dim(' l load · f ${action} · esc back'))553- return out.str()554634 }555635 556636 fn trim_last(text string) string {557- if text.len == 0 {637+ runes := text.runes()638+ if runes.len == 0 {558639 return text559640 }560- return text[..text.len - 1]641+ return runes[..runes.len - 1].string()561642 }562643 563-// ask_confirm is the strict prompt inside the view: only a lowercase y.564-fn ask_confirm(what string) bool {565- paint('\x1b[H\x1b[2J ${what} — continue? [y/N] ')566- return read_key().text == 'y'567-}568-569-// copy_id puts the ID on the clipboard through OSC 52, which works over ssh and570-// in any terminal without a helper program.571-fn copy_id(save_id string) {644+// copy_selected puts the ID on the clipboard through OSC 52, which works over645+// ssh and in any terminal without a helper program.646+fn copy_selected(mut screen Screen, save_id string) {572647 paint('\x1b]52;c;${base64.encode(save_id.bytes())}\a')648+ screen.status = 'copied ${save_id}'573649 }574650 575651 fn parent_tree(store &storage.Store, save history.Save) snapshot.Tree {@@ -631,8 +707,4 @@631707 .saves { Tab.checkpoints }632708 else { Tab.all }633709 }634-}635-636-fn or_dash(value string) string {637- return if value == '' { '—' } else { value }638710 }src/view/view_test.v+192 −74
@@ -4,6 +4,7 @@44 import src.fmt55 import src.history66 import src.snapshot7+import src.storage78 89 fn save(id string, message string, checkpoint bool) history.Save {910 return history.Save{@@ -23,6 +24,27 @@2324 return out2425 }2526 27+fn plain(lines []string) []string {28+ return lines.map(fmt.strip_ansi(it))29+}30+31+// never_act stands in for the command layer where no action may happen yet.32+fn never_act(_ &storage.Store, action Action) string {33+ panic('an action ran without the strict confirm: ${action.kind}')34+}35+36+fn list_session(items []history.Save) Session {37+ return Session{38+ screen: Screen{39+ tab: .all40+ items: items41+ on_line: line_map(items.map(it.id))42+ rows: 3043+ cols: 8044+ }45+ }46+}47+2648 fn test_tabs_filter_by_what_is_on_the_line() {2749 // A combine replays the local save `bbb` as `ddd`: the copy is on the line,2850 // the original is not.@@ -39,19 +61,6 @@3961 assert filter_saves(items, .checkpoints, on_the_line, '').map(it.id) == ['ccc']4062 }4163 42-fn test_only_the_replayed_away_original_is_marked() {43- on_the_line := line_map(['ddd'])44- mut copy := save('ddd', 'my work, replayed', false)45- copy.replayed_from = 'bbb'46- screen := Screen{47- on_line: on_the_line48- }49- assert fmt.strip_ansi(save_mark(save('bbb', 'my work', false), screen)) == '↪'50- assert fmt.strip_ansi(save_mark(copy, screen)) == ' '51- assert entry_line(save('bbb', 'my work', false), false, screen).contains('replayed away')52- assert !entry_line(copy, false, screen).contains('replayed away')53-}54-5564 fn test_search_is_case_insensitive_across_the_fields() {5665 on_the_line := line_map(['aaa', 'bbb', 'ccc'])5766 mut with_agent := save('bbb', 'fix chunk boundary', false)@@ -73,12 +82,154 @@7382 assert move_selection(0, -1, 3) == 07483 assert move_selection(0, 0, 0) == 07584 assert move_selection(5, 1, 0) == 076- // G asks for a move past the end and lands on the last entry.7785 assert move_selection(3, -1, 3) == 27886 }7987 88+// A long message used to wrap, which scrolled the terminal and pushed the tab89+// bar off the top: every line of a frame must fit, and the frame must fill90+// the screen exactly.91+fn test_frame_fills_the_screen_and_never_wraps() {92+ mut items := []history.Save{}93+ for i in 0 .. 20 {94+ items << save('id${i:09}', 'a very long message '.repeat(12), i % 3 == 0)95+ }96+ for tab in [Tab.all, .saves, .checkpoints] {97+ mut session := list_session(items)98+ session.screen.tab = tab99+ session.screen.cols = 60100+ session.screen.selected = 7101+ lines := frame_lines(session)102+ assert lines.len == 30103+ for line in lines {104+ assert fmt.visible_width(line) <= 60105+ }106+ assert plain(lines)[0].contains('checkpoints')107+ }108+ frame := render(frame_lines(list_session(items)))109+ assert frame.starts_with('\x1b[H')110+ assert frame.count('\x1b[2K') == 30111+ // No newline after the last row, or the terminal scrolls one line.112+ assert !frame.ends_with('\n')113+}114+115+fn test_head_has_its_own_mark_and_every_other_save_a_dot() {116+ items := [save('aaa', 'newest', false), save('bbb', 'a checkpoint', true),117+ save('ccc', 'older', false)]118+ mut session := list_session(items)119+ session.screen.head = 'aaa'120+ session.screen.selected = 2121+ text := plain(frame_lines(session)).join('\n')122+ assert text.contains('▸ aaa HEAD')123+ assert text.contains('· bbb')124+ assert text.contains('· ccc')125+ assert !text.contains('◐')126+}127+128+fn test_the_selected_save_adds_size_agent_and_session() {129+ mut first := save('aaa', 'first', false)130+ first.agent = 'claude-opus-5-5'131+ first.session = 'history view'132+ mut session := list_session([first, save('bbb', 'second', false)])133+ text := plain(frame_lines(session)).join('\n')134+ assert text.contains('1.0 KiB · claude-opus-5-5 · history view')135+ session.screen.selected = 1136+ unselected := plain(frame_lines(session)).join('\n')137+ assert !unselected.contains('claude-opus-5-5')138+ assert unselected.contains('unknown · no session')139+}140+141+fn test_flagged_and_replayed_away_saves_are_marked() {142+ mut session := list_session([save('aaa', 'risky', false), save('bbb', 'my work', false)])143+ session.screen.flags['aaa'] = events.Flag{144+ save_id: 'aaa'145+ flagged: true146+ }147+ session.screen.on_line = line_map(['aaa'])148+ text := plain(frame_lines(session)).join('\n')149+ assert text.contains('⚠ risky')150+ assert text.contains('[flagged]')151+ assert text.contains('bbb [replayed away]')152+}153+154+fn test_typing_searches_only_after_a_slash() {155+ mut session := list_session([save('aaa', 'first', false)])156+ assert !handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)157+ assert session.mode == .list158+ assert session.screen.query == ''159+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)160+ assert session.mode == .search161+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'x' }, never_act)162+ assert session.screen.query == 'x'163+}164+165+fn test_q_quits_from_the_list_and_while_searching() {166+ mut session := list_session([save('aaa', 'first', false)])167+ assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)168+ session.mode = .search169+ assert handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'q' }, never_act)170+}171+172+fn test_the_search_bar_shows_whether_it_is_being_typed() {173+ mut session := list_session([save('aaa', 'first', false)])174+ for key in ['/', 'f', 'i'] {175+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)176+ }177+ typing := plain(frame_lines(session))178+ assert typing[0].contains('/fi_')179+ assert typing.last().contains('type to filter · enter keep · esc clear')180+ handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)181+ kept := plain(frame_lines(session))182+ assert kept[0].contains('/fi') && !kept[0].contains('/fi_')183+ assert kept.last().contains('/ search · tab mode')184+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: '/' }, never_act)185+ handle_key(mut session, unsafe { nil }, Key{ kind: .escape }, never_act)186+ assert session.screen.query == ''187+}188+189+fn test_g_goes_to_the_oldest_and_shift_g_to_the_newest() {190+ mut session := list_session([save('aaa', 'new', false), save('bbb', 'mid', false),191+ save('ccc', 'old', false)])192+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'g' }, never_act)193+ assert session.screen.selected == 2194+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'G' }, never_act)195+ assert session.screen.selected == 0196+}197+198+fn test_tab_cycles_the_tabs_and_starts_at_the_top() {199+ mut session := list_session([save('aaa', 'new', false), save('bbb', 'old', false)])200+ session.screen.selected = 1201+ handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)202+ assert session.screen.tab == .saves203+ assert session.screen.selected == 0204+ handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)205+ handle_key(mut session, unsafe { nil }, Key{ kind: .tab }, never_act)206+ assert session.screen.tab == .all207+}208+209+fn test_flagging_asks_the_strict_confirm_before_anything_happens() {210+ mut session := list_session([save('aaa', 'risky', false)])211+ handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)212+ assert session.mode == .detail213+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'f' }, never_act)214+ assert session.mode == .reason215+ // q is text here: a reason may say "quick hack".216+ for key in ['q', 'u', 'i', 'c', 'k'] {217+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: key }, never_act)218+ }219+ handle_key(mut session, unsafe { nil }, Key{ kind: .enter }, never_act)220+ assert session.mode == .confirm221+ assert session.pending.kind == .flag222+ assert session.pending.reason == 'quick'223+ assert plain(frame_lines(session)).last().contains('flag [aaa]? y yes · n/esc cancel')224+ // Only a lowercase y goes ahead; anything else cancels.225+ handle_key(mut session, unsafe { nil }, Key{ kind: .rune, text: 'Y' }, never_act)226+ assert session.mode == .list227+ assert session.pending.kind == .none228+}229+80230 fn test_detail_lists_files_with_stats_and_notes_binaries() {81- target := save('aaa', 'change two files', false)231+ mut target := save('aaa', 'change two files', false)232+ target.parent = 'ppp'82233 flag := events.Flag{83234 save_id: 'aaa'84235 flagged: true@@ -97,71 +248,38 @@97248 binary: true98249 },99250 ]100- text := detail_lines(target, deltas, flag).join('\n')101- assert text.contains('checksum aaa')102- assert text.contains('flag sketchy')103- assert text.contains('src/main.v')104- assert text.contains('+4')105- assert text.contains('-2')106- assert text.contains('logo.png')107- assert text.contains('(binary)')108- assert text.contains('2 files changed against the parent')251+ text := plain(detail_lines(target, '', deltas, flag)).join('\n')252+ assert text.contains('checksum aaa')253+ assert text.contains('flag sketchy')254+ assert text.contains('M src/main.v +4 -2')255+ assert text.contains('A logo.png (binary)')256+ assert text.contains('2 files changed')109257 }110258 111-fn test_detail_says_so_when_nothing_changed() {112- target := save('aaa', 'first save', false)113- text := detail_lines(target, [], events.Flag{259+fn test_detail_of_the_first_save_says_there_is_no_parent() {260+ text := plain(detail_lines(save('aaa', 'first save', false), '', [], events.Flag{114261 save_id: 'aaa'115- }).join('\n')116- assert text.contains('nothing changed against the parent')262+ })).join('\n')263+ assert text.contains('initial save — no parent to diff against')117264 }118265 119-fn test_frame_shows_counts_the_selected_entry_and_the_status_bar() {120- on_the_line := line_map(['aaa', 'ccc'])121- items := [save('aaa', 'first', false), save('ccc', 'before storage', true)]122- screen := Screen{123- tab: .all124- items: items125- selected: 1126- on_line: on_the_line127- counts: count_tabs(items, on_the_line)128- rows: 24129- cols: 100130- status: '3 shown'266+fn test_detail_fits_the_frame_too() {267+ mut session := list_session([save('aaa', 'x'.repeat(300), false)])268+ session.mode = .detail269+ session.target = session.screen.items[0]270+ session.detail = detail_lines(session.target, '', [], events.Flag{})271+ lines := frame_lines(session)272+ assert lines.len == 30273+ for line in lines {274+ assert fmt.visible_width(line) <= 80131275 }132- text := frame_lines(screen).join('\n')133- assert text.contains('all (2)')134- assert text.contains('checkpoints (1)')135- assert text.contains('before storage')136- assert text.contains('aaa')137- // The selected entry carries its extra line.138- assert text.contains('agent')139- assert text.contains('◐')140- assert text.contains('3 shown')141276 }142277 143-fn test_frame_says_no_results_when_the_search_matches_nothing() {144- screen := Screen{145- tab: .all146- rows: 24147- cols: 80148- query: 'nothing matches this'149- counts: TabCounts{}150- }151- text := frame_lines(screen).join('\n')152- assert text.contains('no results')153- assert text.contains('/nothing matches this')154-}155-156-fn test_render_clears_each_line_before_drawing_it() {157- screen := Screen{158- tab: .saves159- rows: 6160- cols: 60161- }162- frame := render(screen)163- assert frame.starts_with('\x1b[H')164- // One line-clear per row drawn, so a shorter frame cannot leave the tail of165- // a longer one behind.166- assert frame.count('\x1b[2K') == frame_lines(screen).len278+fn test_an_action_result_shows_even_on_a_narrow_screen() {279+ mut session := list_session([save('aaa', 'first', false)])280+ session.screen.cols = 50281+ session.screen.status = 'flagged [70c78decf279]'282+ last := plain(frame_lines(session)).last()283+ assert last.ends_with('flagged [70c78decf279]')284+ assert fmt.visible_width(last) <= 50167285 }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.64'3+ version: '3.0.65'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }