diff --git a/lib/hive/tui/bubble_model.rb b/lib/hive/tui/bubble_model.rb index d42249e3..33ea454a 100644 --- a/lib/hive/tui/bubble_model.rb +++ b/lib/hive/tui/bubble_model.rb @@ -32,7 +32,7 @@ require "hive/tui/views/log_tail" require "hive/tui/views/red_status_detail" require "hive/tui/views/help_overlay" require "hive/tui/views/filter_prompt" -require "hive/tui/views/idea_preview" +require "hive/tui/views/info_panel" require "hive/tui/views/new_idea_prompt" require "hive/tui/views/new_idea_project_picker" @@ -227,7 +227,7 @@ module Hive when :red_status_detail then Views::RedStatusDetail.render(@hive_model) when :help then Views::HelpOverlay.render(@hive_model) when :filter then compose_filter_view - when :idea_preview then compose_idea_preview_view + when :idea_preview then Views::InfoPanel.render(@hive_model) when :new_idea_project then compose_new_idea_project_view when :new_idea then compose_new_idea_view else compose_two_pane_view @@ -865,12 +865,9 @@ module Hive return [ model, cmd ] unless model return [ model, cmd ] unless model.mode == :red_status_detail + row = model.red_status_detail_state&.row closed = model.with(mode: :grid, red_status_detail_state: nil) - visible = Hive::Tui::Update.visible_snapshot(closed) - if visible - cursor = Hive::Tui::Update.reclamp_cursor(visible, closed.cursor) - closed = closed.with(cursor: cursor) - end + closed = Hive::Tui::Update.restore_cursor_to_row(closed, row) [ closed, cmd ] end @@ -1356,7 +1353,7 @@ module Hive tail = Hive::Tui::LogTail::Tail.new(log_path) tail.open! - wrapper = LogTailContext.new(tail: tail, claude_pid_alive: row.claude_pid_alive) + wrapper = LogTailContext.new(tail: tail, claude_pid_alive: row.claude_pid_alive, row: row) [ @hive_model.with(mode: :log_tail, tail_state: wrapper), log_tail_poll_cmd ] rescue Hive::NoLogFiles [ flashed("no logs yet for #{row.slug}"), nil ] @@ -1514,29 +1511,170 @@ module Hive [ flashed("editor command invalid: #{e.message}"), nil ] end + # Open the full-screen info panel for the focused row. Read-only: + # every field is snapshotted once here; missing/unreadable pieces + # become nil so the view can render `(unavailable)` without aborting. + # Mode symbol stays `:idea_preview` (help-screen non-goal). def open_idea_preview(row) - return [ flashed("no idea for #{row.slug}"), nil ] if row.folder.to_s.empty? + state = build_info_panel_state(row) + [ @hive_model.with(mode: :idea_preview, info_panel_state: state), nil ] + end + + def build_info_panel_state(row) + folder = row.folder.to_s + created_at, idea_text = read_idea_fields(folder) + latest_log_path = resolve_latest_log_path(folder, row.slug) + extra_title, extra_body = read_stage_extra(folder, row.stage, latest_log_path) + + Hive::Tui::Model::InfoPanelState.new( + row: row, + slug: row.slug.to_s, + stage: row.stage.to_s, + created_at: created_at, + idea_text: idea_text, + folder_path: folder.empty? ? nil : folder, + latest_log_path: latest_log_path, + extra_title: extra_title, + extra_body: extra_body + ) + end - idea_path = File.join(row.folder, "idea.md") - return [ flashed("no idea.md for #{row.slug}"), nil ] unless File.exist?(idea_path) + def read_idea_fields(folder) + return [ nil, nil ] if folder.empty? - data = idea_frontmatter(File.read(idea_path)) - original_text = data["original_text"].to_s - if original_text.empty? - return [ flashed("idea has no original_text for #{row.slug}"), nil ] + idea_path = File.join(folder, "idea.md") + contents = bounded_file_read( + idea_path, + cap: Hive::Tui::Model::InfoPanelState::IDEA_FILE_MAX_BYTES + ) + return [ nil, nil ] if contents.nil? + + data = idea_frontmatter(contents) + created_at = format_info_panel_timestamp(data["created_at"]) + original = data["original_text"].to_s + idea_text = if original.empty? + nil + else + original[0, Hive::Tui::Model::InfoPanelState::IDEA_TEXT_MAX_CHARS] end + [ created_at, idea_text ] + rescue Errno::ENOENT, Errno::EACCES, Psych::Exception + [ nil, nil ] + end - capped_text = original_text[0, Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS] - [ - @hive_model.with( - mode: :idea_preview, - idea_preview_text: capped_text, - idea_preview_slug: row.slug - ), + # Derive `/logs/` from + # `row.folder` (`…/stages//`). Falls back to nil when + # the path shape is unexpected or the dir is empty. + def resolve_latest_log_path(folder, slug) + return nil if folder.to_s.empty? || slug.to_s.empty? + + log_dir = info_panel_log_dir(folder, slug) + return nil if log_dir.nil? || !File.directory?(log_dir) + + latest_regular_file(log_dir) + rescue Errno::ENOENT, Errno::EACCES + nil + end + + def info_panel_log_dir(folder, slug) + # Prefer Task when the folder matches the canonical layout so + # hive_state_path stays shared with the rest of hive. + task = Hive::Task.new(folder) + task.log_dir + rescue Hive::InvalidTaskPath + # folder = /stages// + stages_parent = File.dirname(File.dirname(folder.to_s)) + File.join(stages_parent, "logs", slug.to_s) + end + + def latest_regular_file(dir) + entries = Dir.children(dir).filter_map do |name| + path = File.join(dir, name) + next unless File.file?(path) + + [ path, File.mtime(path) ] + rescue Errno::ENOENT nil - ] - rescue Errno::ENOENT, Errno::EACCES, Psych::Exception - [ flashed("could not read idea for #{row.slug}"), nil ] + end + return nil if entries.empty? + + entries.max_by(&:last).first + rescue Errno::ENOENT, Errno::EACCES + nil + end + + def read_stage_extra(folder, stage, latest_log_path) + case stage.to_s + when "2-brainstorm" + body = bounded_file_read(File.join(folder, "brainstorm.md")) unless folder.to_s.empty? + [ "brainstorm.md", body ] + when "3-plan" + body = bounded_file_read(File.join(folder, "plan.md")) unless folder.to_s.empty? + [ "plan.md", body ] + when "4-execute" + body = latest_log_path ? bounded_log_tail(latest_log_path) : nil + [ "execute log (tail)", body ] + else + [ nil, nil ] + end + end + + def bounded_file_read(path, cap: Hive::Tui::Model::InfoPanelState::EXTRA_BODY_MAX_BYTES) + return nil unless File.file?(path) + + File.open(path, "rb") do |io| + data = io.read(cap) + return nil if data.nil? || data.empty? + + data.force_encoding(Encoding::UTF_8) + data = data.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless data.valid_encoding? + data + end + rescue Errno::ENOENT, Errno::EACCES + nil + end + + def bounded_log_tail(path) + return nil unless File.file?(path) + + cap = Hive::Tui::Model::InfoPanelState::EXTRA_BODY_MAX_BYTES + lines_wanted = Hive::Tui::Model::InfoPanelState::LOG_TAIL_LINES + File.open(path, "rb") do |io| + size = io.size + start = [ size - cap, 0 ].max + starts_mid_line = if start.positive? + io.seek(start - 1) + io.read(1) != "\n" + else + false + end + io.seek(start) + data = io.read(cap).to_s + data.force_encoding(Encoding::UTF_8) + data = data.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless data.valid_encoding? + lines = data.each_line.map(&:chomp) + # Drop a partial prefix only when the bounded window also + # contains a later line. A large single-line log has no later + # line to show, so its bounded suffix remains useful output. + lines = lines.drop(1) if starts_mid_line && lines.length > 1 + lines.last(lines_wanted).join("\n") + end + rescue Errno::ENOENT, Errno::EACCES + nil + end + + def format_info_panel_timestamp(value) + return nil if value.nil? + + case value + when Time + value.utc.iso8601 + when Date + value.iso8601 + else + text = value.to_s + text.empty? ? nil : text + end end def idea_frontmatter(contents) @@ -2406,11 +2544,12 @@ module Hive # Views::LogTail. Carries the underlying Tail so we can call # `close!` on Back without leaking file descriptors. class LogTailContext - attr_reader :tail, :claude_pid_alive + attr_reader :tail, :claude_pid_alive, :row - def initialize(tail:, claude_pid_alive:) + def initialize(tail:, claude_pid_alive:, row: nil) @tail = tail @claude_pid_alive = claude_pid_alive + @row = row end def path @@ -2884,11 +3023,6 @@ module Hive compose_two_pane_view(footer: prompt_footer(Views::FilterPrompt.render(@hive_model, width: usable), usable)) end - def compose_idea_preview_view - usable = [ @hive_model.cols.to_i - 1, 1 ].max - compose_two_pane_view(footer: prompt_footer(Views::IdeaPreview.render(@hive_model, width: usable), usable)) - end - # New-idea mode: same composition; footer = the inline prompt with # the project label so the operator sees the resolved target. # Width clamps the prompt so long titles don't overflow the @@ -3002,8 +3136,8 @@ module Hive # Default footer — context-aware key hints + flash decay (the # status line). v1 had this in Views::Grid#status_line; lifted here # so the panes stay layout-only. `usable_width` clamps the line - # so the fixed 75-char hint string doesn't overflow narrow - # terminals (e.g. cols=70 used to wrap onto a second visible row). + # so the ~79-char hint string doesn't overflow narrow terminals + # (truncation via Views::Format.truncate keeps a single footer row). def default_footer(usable_width = nil) if @hive_model.flash_active? line = @hive_model.flash.to_s @@ -3017,7 +3151,7 @@ module Hive end def footer_hint - "[Tab] switch [Enter] action [n] new [/] filter [?] help [q] quit" + "[Tab] switch [Enter] action [n] new [/] filter [?] help [i] info [q] quit" end # Compute pane widths and join horizontally. Left pane is clamped diff --git a/lib/hive/tui/key_map.rb b/lib/hive/tui/key_map.rb index 8233e9a4..a8518d80 100644 --- a/lib/hive/tui/key_map.rb +++ b/lib/hive/tui/key_map.rb @@ -402,11 +402,13 @@ module Hive Messages::BACK end - # Idea preview is read-only: every key closes it and returns to - # grid, whether Bubble Tea emitted a printable String or a - # special-key Symbol. + # Full-screen info panel (`:idea_preview` mode): close only on + # q / Esc / i. Every other key is a no-op so the panel stays open + # (selection is preserved by apply_back on the close path). def idea_preview_message(key:, row:) # rubocop:disable Lint/UnusedMethodArgument - Messages::BACK + return Messages::BACK if key == "q" || key == "i" || ESCAPE_KEYS.include?(key) + + Messages::NOOP end # New-idea prompt mode — same key shape as `:filter` mode but diff --git a/lib/hive/tui/model.rb b/lib/hive/tui/model.rb index b81a44c0..fe085cff 100644 --- a/lib/hive/tui/model.rb +++ b/lib/hive/tui/model.rb @@ -40,8 +40,10 @@ module Hive # asset. Resets only on open / cancel / submit. :new_idea_attachment_counter, :new_idea_broken_labels, # Array — labels highlighted after rich-submit validation fails - :idea_preview_text, # String or nil — original_text rendered in :idea_preview mode - :idea_preview_slug, # String or nil — slug captured when the preview opened + # Model::InfoPanelState or nil — :idea_preview mode only (mode + # symbol kept for help-screen non-goal; state is the full-screen + # info panel snapshot). + :info_panel_state, :flash, # String or nil — current status-line message :flash_set_at, # Time or nil — flash decay timestamp :tail_state, # Hive::Tui::LogTail::Tail or nil — :log_tail mode only @@ -104,6 +106,47 @@ module Hive end end + # Read-only snapshot assembled when `i` opens the info panel + # (`:idea_preview` mode). All file I/O happens once at open time in + # BubbleModel; the view is a pure projection of these fields. + # Missing pieces are nil and render as `(unavailable)`. + Model::InfoPanelState = Data.define( + :row, + :slug, + :stage, + :created_at, + :idea_text, + :folder_path, + :latest_log_path, + :extra_title, + :extra_body + ) + # Bounded open-time reads so a multi-MB brainstorm/plan/log cannot + # bloat the model or stall a frame. + Model::InfoPanelState::IDEA_TEXT_MAX_CHARS = Model::NEW_IDEA_BUFFER_MAX_CHARS + # The accepted composer input can contain 4-byte UTF-8 characters. + # Leave a fixed allowance for frontmatter keys, the maximum slug, + # timestamp, YAML indentation, and closing delimiter so every valid + # 4,096-character idea can be parsed without making the read unbounded. + Model::InfoPanelState::IDEA_FILE_MAX_BYTES = (Model::NEW_IDEA_BUFFER_MAX_CHARS * 4) + 1024 + Model::InfoPanelState::EXTRA_BODY_MAX_BYTES = 8 * 1024 + Model::InfoPanelState::LOG_TAIL_LINES = 40 + class Model::InfoPanelState + def initialize( + row: nil, + slug:, + stage:, + created_at: nil, + idea_text: nil, + folder_path: nil, + latest_log_path: nil, + extra_title: nil, + extra_body: nil + ) + super + end + end + class Model # Boot state. App.run constructs the runner with this Model. # `pane_focus` defaults to `:right` so the table is the first @@ -128,8 +171,7 @@ module Hive new_idea_staging_tmp_root: nil, new_idea_attachment_counter: 0, new_idea_broken_labels: [], - idea_preview_text: nil, - idea_preview_slug: nil, + info_panel_state: nil, flash: nil, flash_set_at: nil, tail_state: nil, diff --git a/lib/hive/tui/update.rb b/lib/hive/tui/update.rb index f17a433b..ca29d84e 100644 --- a/lib/hive/tui/update.rb +++ b/lib/hive/tui/update.rb @@ -777,12 +777,19 @@ end # this Message). def apply_back(model) case model.mode - when :log_tail then model.with(mode: :grid, tail_state: nil) + when :log_tail + state = model.tail_state + closed = model.with(mode: :grid, tail_state: nil) + row = state.row if state.respond_to?(:row) + restore_cursor_to_row(closed, row) when :red_status_detail + row = model.red_status_detail_state&.row closed = model.with(mode: :grid, red_status_detail_state: nil) - visible = visible_snapshot(closed) - visible.nil? ? closed : closed.with(cursor: reclamp_cursor(visible, closed.cursor)) - when :idea_preview then model.with(mode: :grid, idea_preview_text: nil, idea_preview_slug: nil) + restore_cursor_to_row(closed, row) + when :idea_preview + row = model.info_panel_state&.row + closed = model.with(mode: :grid, info_panel_state: nil) + restore_cursor_to_row(closed, row) when :help, :filter then model.with(mode: :grid) when :new_idea_project then apply_new_idea_cancelled(model) else model @@ -816,8 +823,45 @@ end def find_row_for_detail(snapshot, original_row) return nil if snapshot.nil? || original_row.nil? - snapshot.rows.find { |row| row.folder == original_row.folder } || - snapshot.rows.find { |row| row.project_name == original_row.project_name && row.slug == original_row.slug && row.stage == original_row.stage } + cursor = cursor_for_row(snapshot, original_row) + snapshot.row_at(cursor) + end + + # Restore card-derived overlays to the row that opened them, even + # when a status poll reordered still-valid cursor coordinates. + # If the row disappeared, retain the existing graceful fallback: + # clamp to the first visible row (or nil for an empty grid). + def restore_cursor_to_row(model, row) + visible = visible_snapshot(model) + return model if visible.nil? + + cursor = cursor_for_row(visible, row) || reclamp_cursor(visible, model.cursor) + model.with(cursor: cursor) + end + + def cursor_for_row(snapshot, original_row) + return nil if snapshot.nil? || original_row.nil? + + folder = original_row.folder.to_s + unless folder.empty? + cursor = find_row_cursor(snapshot) { |row| row.folder.to_s == folder } + return cursor if cursor + end + + find_row_cursor(snapshot) do |row| + row.project_name == original_row.project_name && + row.slug == original_row.slug && + row.stage == original_row.stage + end + end + + def find_row_cursor(snapshot) + snapshot.projects.each_with_index do |project, project_idx| + project.rows.each_with_index do |row, row_idx| + return [ project_idx, row_idx ] if yield(row) + end + end + nil end def red_status_row?(row) diff --git a/lib/hive/tui/views/idea_preview.rb b/lib/hive/tui/views/idea_preview.rb deleted file mode 100644 index c9fdfb09..00000000 --- a/lib/hive/tui/views/idea_preview.rb +++ /dev/null @@ -1,60 +0,0 @@ -require "hive/tui/styles" -require "hive/tui/views/format" - -module Hive - module Tui - module Views - # Bottom-strip preview for a task's source idea.md original_text. - # Read-only: KeyMap routes every key in :idea_preview mode back - # to grid; this view only renders the captured model fields. - module IdeaPreview - DISMISS_HINT = "press any key to dismiss".freeze - MAX_VISIBLE_ROWS = 6 - - module_function - - def render(model, width: model.cols.to_i) - usable = [ width.to_i, 1 ].max - rows = [ - Styles::HINT.render(truncate("Idea for #{model.idea_preview_slug}:", usable)), - *body_rows(model.idea_preview_text.to_s, usable), - Styles::HINT.render(truncate(DISMISS_HINT, usable)) - ] - rows.join("\n") - end - - def body_rows(text, width) - return [] if text.empty? - - wrap_text(text, width).first(MAX_VISIBLE_ROWS).map { |line| truncate(line, width) } - end - - # Intentional local copy of NewIdeaPrompt's simple chunking shape. - # NewIdeaPrompt's helper is cursor-aware and attachment-aware; - # extracting it would widen this read-only view change. - def chunk_buffer(buffer, capacity) - return [ "" ] if buffer.empty? - - chunks = [] - offset = 0 - while offset < buffer.length - chunks << buffer[offset, capacity].to_s - offset += capacity - end - chunks - end - - def wrap_text(text, width) - capacity = [ width.to_i, 1 ].max - text.each_line(chomp: true).flat_map do |line| - chunk_buffer(line, capacity) - end - end - - def truncate(line, width) - Views::Format.truncate(line, width.to_i) - end - end - end - end -end diff --git a/lib/hive/tui/views/info_panel.rb b/lib/hive/tui/views/info_panel.rb new file mode 100644 index 00000000..42bcc500 --- /dev/null +++ b/lib/hive/tui/views/info_panel.rb @@ -0,0 +1,122 @@ +require "hive/tui/styles" +require "hive/tui/text" +require "hive/tui/views/format" + +module Hive + module Tui + module Views + # Full-screen read-only info panel for a focused task card. + # Renders `Model::InfoPanelState` assembled at open time; pure + # projection — no I/O. Mode symbol remains `:idea_preview`. + # + # Overflow is truncated with a trailing `…` line (scrolling is a + # deferred follow-up). Missing fields render as `(unavailable)`. + module InfoPanel + CLOSE_HINT = "[q]/[Esc]/[i] close".freeze + UNAVAILABLE = "(unavailable)".freeze + ELLIPSIS = "…".freeze + + module_function + + def render(model) + state = model.info_panel_state + return "" if state.nil? + + width = [ model.cols.to_i - 1, 1 ].max + height = [ model.rows.to_i, 1 ].max + body_budget = [ height - 1, 0 ].max + + body = content_lines(state, width) + body = truncate_body(body, body_budget, width) + footer = Styles::HINT.render(truncate(CLOSE_HINT, width)) + (body + [ footer ]).join("\n") + end + + def content_lines(state, width) + lines = [] + lines << Styles::HEADER.render(truncate("Info · #{display(state.slug)}", width)) + lines << "" + lines << truncate("Slug: #{display(state.slug)}", width) + lines << truncate("Stage: #{display(state.stage)}", width) + lines << truncate("Created: #{display(state.created_at)}", width) + lines.concat(labeled_block("Idea", state.idea_text, width)) + lines << truncate("Working dir: #{display(state.folder_path)}", width) + lines << truncate("Latest log: #{display(state.latest_log_path)}", width) + + if state.extra_title + lines << "" + lines << Styles::HEADER.render(truncate(display(state.extra_title), width)) + lines.concat(body_block(state.extra_body, width)) + end + + lines + end + + def labeled_block(label, value, width) + prefix = "#{label}: " + if value.nil? || value.to_s.empty? + [ truncate("#{prefix}#{UNAVAILABLE}", width) ] + else + first_width = [ width.to_i - prefix.length, 0 ].max + first, *rest = wrap_text(value.to_s, width, first_width: first_width) + [ truncate("#{prefix}#{first}", width) ] + rest.map { |line| truncate(line, width) } + end + end + + def body_block(value, width) + if value.nil? || value.to_s.empty? + [ truncate(UNAVAILABLE, width) ] + else + wrap_text(value.to_s, width).map { |line| truncate(line, width) } + end + end + + def truncate_body(lines, budget, width) + return [] if budget <= 0 + return lines if lines.length <= budget + return [ truncate(ELLIPSIS, width) ] if budget <= 1 + + lines.first(budget - 1) + [ truncate(ELLIPSIS, width) ] + end + + def display(value) + text = value.to_s + text.empty? ? UNAVAILABLE : Hive::Tui::Text.sanitize(text) + end + + # Intentional local copy of IdeaPreview/NewIdeaPrompt chunking — + # cursor/attachment-aware helpers elsewhere would widen this + # pure render surface. + def chunk_buffer(buffer, capacity) + return [ "" ] if buffer.empty? + + chunks = [] + offset = 0 + while offset < buffer.length + chunks << buffer[offset, capacity].to_s + offset += capacity + end + chunks + end + + def wrap_text(text, width, first_width: width) + capacity = [ width.to_i, 1 ].max + text.each_line(chomp: true).each_with_index.flat_map do |line, index| + line = Hive::Tui::Text.sanitize(line) + if index.zero? && first_width.to_i < capacity + prefix_chunk = line[0, [ first_width.to_i, 0 ].max].to_s + remainder = line[prefix_chunk.length..].to_s + [ prefix_chunk ] + (remainder.empty? ? [] : chunk_buffer(remainder, capacity)) + else + line.empty? ? [ "" ] : chunk_buffer(line, capacity) + end + end + end + + def truncate(line, width) + Views::Format.truncate(line, width.to_i) + end + end + end + end +end diff --git a/test/unit/tui/bubble_model_test.rb b/test/unit/tui/bubble_model_test.rb index bfc3e63b..552c5912 100644 --- a/test/unit/tui/bubble_model_test.rb +++ b/test/unit/tui/bubble_model_test.rb @@ -1,5 +1,6 @@ require "test_helper" require "hive/tui/bubble_model" +require "stringio" # Pin the BubbleModel adapter's translation/dispatch contract: # framework messages → Hive Messages, KeyMessage → KeyMap.message_for, @@ -345,18 +346,30 @@ class HiveTuiBubbleModelTest < Minitest::Test assert_includes out, "/auth" end - def test_view_composes_idea_preview_onto_grid_in_idea_preview_mode + def test_view_renders_full_screen_info_panel_in_idea_preview_mode + state = Hive::Tui::Model::InfoPanelState.new( + slug: "some-slug", + stage: "2-brainstorm", + idea_text: "original idea", + folder_path: "/tmp/some-slug", + extra_title: "brainstorm.md", + extra_body: "notes" + ) @model = Hive::Tui::BubbleModel.new( hive_model: Hive::Tui::Model.initial.with( mode: :idea_preview, - idea_preview_slug: "some-slug", - idea_preview_text: "original idea" + info_panel_state: state, + cols: 80, + rows: 24 ), dispatch: @dispatch ) out = @model.view - assert_includes out, "Idea for some-slug:" + assert_includes out, "Info · some-slug" assert_includes out, "original idea" + assert_includes out, "brainstorm.md" + assert_includes out, Hive::Tui::Views::InfoPanel::CLOSE_HINT + refute_includes out, "[Tab] switch", "info panel replaces the grid footer strip" end # Regression: paste-truncated / paste-timeout / overflow flashes @@ -603,28 +616,21 @@ class HiveTuiBubbleModelTest < Minitest::Test refute_includes out, "[Enter] open", "Enter is not only an open action" end - def test_default_footer_hint_omits_o_at_70_col_budget - # Plan R6: `[o] open` is included in the footer only if it fits - # the 70-col budget without wrapping or pushing primary actions - # onto a second line. At 70 cols the current hint string is - # already ~69 chars; adding ten more (separator + "[o] open") - # would exceed the budget. We rely on the `?` overlay for - # discoverability instead. This test pins that decision so a - # future contributor doesn't silently re-add the hint and break - # 70-col rendering. + def test_footer_hint_includes_i_info_between_help_and_quit hint = @model.send(:footer_hint) - assert_equal "[Tab] switch [Enter] action [n] new [/] filter [?] help [q] quit", - hint, - "footer hint must remain the pre-`o` literal; `o` is documented in `?` only" + assert_includes hint, "[?] help [i] info [q] quit" + assert_equal "[Tab] switch [Enter] action [n] new [/] filter [?] help [i] info [q] quit", + hint refute_includes hint, "[o] open", - "70-col budget can't absorb `[o] open` alongside primary hints" - # Width guard: pin the actual character count so a future contributor - # who adds a hint and (correctly) bumps the literal above also has to - # acknowledge they're spending bytes against the 70-col budget. If - # this assertion fires alongside an updated literal, the contributor - # MUST verify default_footer truncation behavior at cols == 70. - assert hint.length <= 70, - "footer hint must fit the 70-col budget without truncation; got #{hint.length} chars" + "`o` stays documented in `?` only; legend budget prefers `[i] info`" + end + + def test_default_footer_truncates_to_single_line_at_narrow_width + footer = @model.send(:default_footer, 40) + # Lipgloss may strip styles in non-tty; assert single logical line + # after width clamp (no wrap onto a second visible row). + assert_equal 1, footer.to_s.lines.length + assert_operator footer.to_s.gsub(/\e\[[0-9;]*m/, "").length, :<=, 40 end def test_grid_mode_collapses_to_single_pane_below_min_cols @@ -3658,87 +3664,317 @@ class HiveTuiBubbleModelTest < Minitest::Test "OpenTaskFolder must not dispatch any follow-up message — no auto-continue, no InputEditorExited" end - # ---- OpenIdeaPreview → bottom-strip preview (read-only) ---- + # ---- OpenIdeaPreview → full-screen info panel (read-only) ---- + + def info_panel_fixture_root(base, stage:, slug:) + folder = File.join(base, ".hive-state", "stages", stage, slug) + FileUtils.mkdir_p(folder) + FileUtils.mkdir_p(File.join(base, ".hive-state", "logs", slug)) + folder + end + + def test_open_idea_preview_builds_full_state_for_brainstorm_row + with_tmp_dir do |base| + folder = info_panel_fixture_root(base, stage: "2-brainstorm", slug: "ship-it") + write_idea_md(folder, original_text: "Build task from user note") + File.write(File.join(folder, "brainstorm.md"), "# Brainstorm\n\nDetails here\n") + log_dir = File.join(base, ".hive-state", "logs", "ship-it") + log_path = File.join(log_dir, "run.log") + File.write(log_path, "log line\n") + row = make_task_row(folder: folder, slug: "ship-it", stage: "2-brainstorm") + + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + + assert_nil cmd + assert_equal :idea_preview, @model.hive_model.mode + state = @model.hive_model.info_panel_state + assert_equal "ship-it", state.slug + assert_equal "2-brainstorm", state.stage + assert_same row, state.row + assert_equal "2026-05-20T00:00:00Z", state.created_at + assert_equal "Build task from user note", state.idea_text + assert_equal folder, state.folder_path + assert_equal log_path, state.latest_log_path + assert_equal "brainstorm.md", state.extra_title + assert_includes state.extra_body, "Details here" + end + end + + def test_open_idea_preview_picks_newest_log_and_tails_for_execute + with_tmp_dir do |base| + folder = info_panel_fixture_root(base, stage: "4-execute", slug: "ship-it") + write_idea_md(folder, original_text: "Execute me") + log_dir = File.join(base, ".hive-state", "logs", "ship-it") + old_log = File.join(log_dir, "old.log") + new_log = File.join(log_dir, "new.log") + File.write(old_log, "old content\n") + cap = Hive::Tui::Model::InfoPanelState::EXTRA_BODY_MAX_BYTES + expected_lines = (1..50).map { |i| "line-#{i}" } + File.write(new_log, "#{"x" * cap}\n#{expected_lines.join("\n")}\n") + older = Time.now - 120 + newer = Time.now - 5 + File.utime(older, older, old_log) + File.utime(newer, newer, new_log) + row = make_task_row(folder: folder, slug: "ship-it", stage: "4-execute") + + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + + assert_nil cmd + state = @model.hive_model.info_panel_state + assert_equal new_log, state.latest_log_path + assert_equal "execute log (tail)", state.extra_title + assert_equal expected_lines.last(40).join("\n"), state.extra_body, + "tail must drop the bounded window's partial prefix and return exactly 40 complete lines" + refute_includes state.extra_body.to_s, "old content" + end + end + + def test_bounded_log_tail_caps_read_when_file_grows_after_size_check + with_tmp_dir do |dir| + path = File.join(dir, "execute.log") + File.write(path, "seed\n") + cap = Hive::Tui::Model::InfoPanelState::EXTRA_BODY_MAX_BYTES + fake_io = Class.new do + attr_reader :read_lengths + + def initialize(initial, growth) + @io = StringIO.new(initial.b) + @growth = growth.b + @read_lengths = [] + end + + def size + observed = @io.string.bytesize + @io.string << @growth + observed + end + + def seek(offset) + @io.seek(offset) + end + + def read(length = nil) + @read_lengths << length + @io.read(length) + end + end.new("seed\n", "x" * (cap * 2)) + + body = File.stub(:open, ->(*_args, &block) { block.call(fake_io) }) do + @model.send(:bounded_log_tail, path) + end + + assert_equal cap, fake_io.read_lengths.last + assert_operator body.bytesize, :<=, cap + end + end + + def test_open_idea_preview_preserves_bounded_suffix_of_single_line_execute_log + with_tmp_dir do |base| + folder = info_panel_fixture_root(base, stage: "4-execute", slug: "ship-it") + write_idea_md(folder, original_text: "Execute me") + cap = Hive::Tui::Model::InfoPanelState::EXTRA_BODY_MAX_BYTES + log_path = File.join(base, ".hive-state", "logs", "ship-it", "execute.log") + File.write(log_path, "old-prefix-#{"x" * cap}-fresh-tail") + row = make_task_row(folder: folder, slug: "ship-it", stage: "4-execute") + + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) - def test_open_idea_preview_reads_original_text_and_enters_preview_mode + assert_nil cmd + body = @model.hive_model.info_panel_state.extra_body + assert_includes body, "fresh-tail" + assert_operator body.bytesize, :<=, cap + end + end + + def test_open_idea_preview_inbox_has_no_stage_extra + with_tmp_dir do |base| + folder = info_panel_fixture_root(base, stage: "1-inbox", slug: "ship-it") + write_idea_md(folder, original_text: "Inbox idea") + row = make_task_row(folder: folder, slug: "ship-it", stage: "1-inbox") + + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + + assert_nil cmd + state = @model.hive_model.info_panel_state + assert_equal "1-inbox", state.stage + assert_nil state.extra_title + assert_nil state.extra_body + end + end + + def test_open_idea_preview_opens_when_idea_md_missing with_tmp_dir do |dir| - write_idea_md(dir, original_text: "Build task from user note") - row = make_task_row(folder: dir, slug: "some-slug") + row = make_task_row(folder: dir, stage: "1-inbox") _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) assert_nil cmd assert_equal :idea_preview, @model.hive_model.mode - assert_equal "Build task from user note", @model.hive_model.idea_preview_text - assert_equal "some-slug", @model.hive_model.idea_preview_slug + state = @model.hive_model.info_panel_state + assert_nil state.created_at + assert_nil state.idea_text + assert_nil @model.hive_model.flash end end - def test_open_idea_preview_flashes_when_folder_empty - row = make_task_row(folder: "") + def test_open_idea_preview_opens_with_empty_folder + row = make_task_row(folder: "", stage: "1-inbox") _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) assert_nil cmd - assert_equal :grid, @model.hive_model.mode - assert_match(/no idea for some-slug/, @model.hive_model.flash.to_s) + assert_equal :idea_preview, @model.hive_model.mode + state = @model.hive_model.info_panel_state + assert_equal "some-slug", state.slug + assert_nil state.folder_path + assert_nil state.idea_text end - def test_open_idea_preview_flashes_when_idea_md_missing + def test_open_idea_preview_does_not_read_root_stage_extras_when_folder_is_empty + reads = [] + @model.define_singleton_method(:bounded_file_read) do |path, **_kwargs| + reads << path + "unrelated root-level content" + end + + { + "2-brainstorm" => "brainstorm.md", + "3-plan" => "plan.md" + }.each do |stage, filename| + row = make_task_row(folder: "", stage: stage) + + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + + assert_nil cmd + state = @model.hive_model.info_panel_state + assert_equal filename, state.extra_title + assert_nil state.extra_body + assert_empty reads + end + end + + def test_open_idea_preview_handles_unreadable_idea_md with_tmp_dir do |dir| + File.write(File.join(dir, "idea.md"), "---\noriginal_text: [broken\n---\n") row = make_task_row(folder: dir) _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) assert_nil cmd - assert_equal :grid, @model.hive_model.mode - assert_match(/no idea\.md for some-slug/, @model.hive_model.flash.to_s) + assert_equal :idea_preview, @model.hive_model.mode + assert_nil @model.hive_model.info_panel_state.idea_text + assert_nil @model.hive_model.flash end end - def test_open_idea_preview_flashes_when_original_text_missing + def test_open_idea_preview_replaces_invalid_utf8_in_idea_md with_tmp_dir do |dir| - File.write(File.join(dir, "idea.md"), "---\nslug: some-slug\n---\n") + bytes = "---\ncreated_at: 2026-05-20T00:00:00Z\noriginal_text: |\n valid".b + bytes << "\xFF".b << "text\n---\n".b + File.binwrite(File.join(dir, "idea.md"), bytes) row = make_task_row(folder: dir) _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) assert_nil cmd - assert_equal :grid, @model.hive_model.mode - assert_match(/idea has no original_text for some-slug/, @model.hive_model.flash.to_s) + assert_equal :idea_preview, @model.hive_model.mode + assert_equal "valid\uFFFDtext", @model.hive_model.info_panel_state.idea_text end end - def test_open_idea_preview_flashes_on_unreadable_idea_md + def test_open_idea_preview_reads_maximum_multibyte_idea_frontmatter with_tmp_dir do |dir| - File.write(File.join(dir, "idea.md"), "---\noriginal_text: [broken\n---\n") + original = "🐝" * Hive::Tui::Model::InfoPanelState::IDEA_TEXT_MAX_CHARS + write_idea_md(dir, original_text: original) row = make_task_row(folder: dir) _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) assert_nil cmd - assert_equal :grid, @model.hive_model.mode - assert_match(/could not read idea for some-slug/, @model.hive_model.flash.to_s) + state = @model.hive_model.info_panel_state + assert_equal "2026-05-20T00:00:00Z", state.created_at + assert_equal original, state.idea_text end end - def test_open_idea_preview_does_not_dispatch_or_mutate_marker + def test_open_idea_preview_uses_placeholders_for_idea_frontmatter_beyond_read_cap with_tmp_dir do |dir| - idea_path = write_idea_md(dir, original_text: "Read only") - before = File.read(idea_path) + max_chars = Hive::Tui::Model::InfoPanelState::IDEA_TEXT_MAX_CHARS + write_idea_md(dir, original_text: "x" * (max_chars * 8)) row = make_task_row(folder: dir) _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + assert_nil cmd + assert_equal :idea_preview, @model.hive_model.mode + assert_nil @model.hive_model.info_panel_state.created_at + assert_nil @model.hive_model.info_panel_state.idea_text + end + end + + def test_open_idea_preview_empty_logs_dir_leaves_latest_log_nil + with_tmp_dir do |base| + folder = info_panel_fixture_root(base, stage: "2-brainstorm", slug: "ship-it") + write_idea_md(folder, original_text: "No logs yet") + row = make_task_row(folder: folder, slug: "ship-it", stage: "2-brainstorm") + + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + + assert_nil cmd + assert_nil @model.hive_model.info_panel_state.latest_log_path + end + end + + def test_open_idea_preview_caps_oversized_plan_body + with_tmp_dir do |base| + folder = info_panel_fixture_root(base, stage: "3-plan", slug: "ship-it") + write_idea_md(folder, original_text: "Plan me") + oversized = "P" * (Hive::Tui::Model::InfoPanelState::EXTRA_BODY_MAX_BYTES + 500) + File.write(File.join(folder, "plan.md"), oversized) + row = make_task_row(folder: folder, slug: "ship-it", stage: "3-plan") + + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + + assert_nil cmd + state = @model.hive_model.info_panel_state + assert_equal "plan.md", state.extra_title + assert_operator state.extra_body.bytesize, :<=, + Hive::Tui::Model::InfoPanelState::EXTRA_BODY_MAX_BYTES + end + end + + def test_open_idea_preview_does_not_mutate_fixture_files + with_tmp_dir do |base| + folder = info_panel_fixture_root(base, stage: "2-brainstorm", slug: "ship-it") + idea_path = write_idea_md(folder, original_text: "Read only") + brainstorm_path = File.join(folder, "brainstorm.md") + File.write(brainstorm_path, "body\n") + before_listing = Dir.glob(File.join(base, "**/*"), File::FNM_DOTMATCH).sort + before_files = before_listing.select { |p| File.file?(p) } + before_mtimes = before_files.to_h { |p| [ p, File.mtime(p) ] } + before_contents = before_files.to_h { |p| [ p, File.read(p) ] } + row = make_task_row(folder: folder, slug: "ship-it", stage: "2-brainstorm") + + sleep 0.01 + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + assert_nil cmd assert_empty @messages - assert_equal before, File.read(idea_path) + after_listing = Dir.glob(File.join(base, "**/*"), File::FNM_DOTMATCH).sort + assert_equal before_listing, after_listing + before_mtimes.each do |path, mtime| + assert_equal mtime, File.mtime(path), "open must not mutate mtime of #{path}" + end + before_contents.each do |path, contents| + assert_equal contents, File.read(path), "open must not mutate contents of #{path}" + end + assert_includes File.read(idea_path), "Read only" end end def test_open_idea_preview_truncates_oversized_original_text with_tmp_dir do |dir| - original = "x" * (Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS + 20) + original = "x" * (Hive::Tui::Model::InfoPanelState::IDEA_TEXT_MAX_CHARS + 20) write_idea_md(dir, original_text: original) row = make_task_row(folder: dir) @@ -3746,32 +3982,52 @@ class HiveTuiBubbleModelTest < Minitest::Test assert_nil cmd assert_equal :idea_preview, @model.hive_model.mode - assert_equal Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS, - @model.hive_model.idea_preview_text.length + assert_equal Hive::Tui::Model::InfoPanelState::IDEA_TEXT_MAX_CHARS, + @model.hive_model.info_panel_state.idea_text.length end end - def test_idea_preview_roundtrip_open_then_any_key_dismisses + def test_idea_preview_roundtrip_open_then_q_closes_preserving_cursor with_tmp_dir do |dir| write_idea_md(dir, original_text: "Roundtrip idea") row = make_task_row(folder: dir) + @model = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial.with(cursor: [ 0, 2 ]), + dispatch: @dispatch + ) _, open_cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) assert_nil open_cmd assert_equal :idea_preview, @model.hive_model.mode - assert_equal "Roundtrip idea", @model.hive_model.idea_preview_text + assert_equal "Roundtrip idea", @model.hive_model.info_panel_state.idea_text - _, dismiss_cmd = @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "x".ord ])) + _, dismiss_cmd = @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "q".ord ])) assert_nil dismiss_cmd assert_equal :grid, @model.hive_model.mode - assert_nil @model.hive_model.idea_preview_text - assert_nil @model.hive_model.idea_preview_slug + assert_nil @model.hive_model.info_panel_state + assert_equal [ 0, 2 ], @model.hive_model.cursor assert_empty @messages end end + def test_idea_preview_unmapped_key_is_noop + with_tmp_dir do |dir| + write_idea_md(dir, original_text: "Stay open") + row = make_task_row(folder: dir) + + @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + state_before = @model.hive_model.info_panel_state + + _, cmd = @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "x".ord ])) + + assert_nil cmd + assert_equal :idea_preview, @model.hive_model.mode + assert_equal state_before, @model.hive_model.info_panel_state + end + end + # ---- OpenInAgent → configured agent foreground takeover ---- def test_open_in_agent_marks_manual_steering_and_spawns_in_worktree_with_context_dirs @@ -5731,6 +5987,7 @@ class HiveTuiBubbleModelTest < Minitest::Test assert_kind_of Bubbletea::TickCommand, cmd, "successful open_log_tail must seed the LOG_TAIL_POLL tick so new bytes drain" assert_equal :log_tail, @model.hive_model.mode + assert_same row, @model.hive_model.tail_state.row end end diff --git a/test/unit/tui/key_map_test.rb b/test/unit/tui/key_map_test.rb index d58b283e..c62220c9 100644 --- a/test/unit/tui/key_map_test.rb +++ b/test/unit/tui/key_map_test.rb @@ -258,13 +258,40 @@ class TuiKeyMapMessageForTest < Minitest::Test assert_equal "i", msg.char end - def test_idea_preview_any_key_returns_back - [ "i", "x", :key_enter, :key_escape, "q", :space ].each do |key| + def test_idea_preview_close_keys_return_back + [ "q", "i", :key_escape, "\e" ].each do |key| msg = Hive::Tui::KeyMap.message_for(mode: :idea_preview, key: key, row: nil) - assert_same Hive::Tui::Messages::BACK, msg, "#{key.inspect} must dismiss idea preview" + assert_same Hive::Tui::Messages::BACK, msg, "#{key.inspect} must close info panel" end end + def test_idea_preview_unmapped_keys_return_noop + [ "j", "x", :key_enter, :key_up, :key_down, "?", "/", :space, "n", "Tab" ].each do |key| + msg = Hive::Tui::KeyMap.message_for(mode: :idea_preview, key: key, row: nil) + assert_same Hive::Tui::Messages::NOOP, msg, "#{key.inspect} must be a no-op while panel is open" + end + end + + def test_grid_keybindings_unchanged_for_primary_keys + row = make_row(action_key: "ready_to_brainstorm") + assert_same Hive::Tui::Messages::PANE_FOCUS_TOGGLED, + Hive::Tui::KeyMap.message_for(mode: :grid, key: :key_tab, row: row) + assert_kind_of Hive::Tui::Messages::DispatchCommand, + Hive::Tui::KeyMap.message_for(mode: :grid, key: :key_enter, row: row) + assert_same Hive::Tui::Messages::OPEN_NEW_IDEA_PROMPT, + Hive::Tui::KeyMap.message_for(mode: :grid, key: "n", row: row) + assert_same Hive::Tui::Messages::OPEN_FILTER_PROMPT, + Hive::Tui::KeyMap.message_for(mode: :grid, key: "/", row: row) + assert_same Hive::Tui::Messages::SHOW_HELP, + Hive::Tui::KeyMap.message_for(mode: :grid, key: "?", row: row) + assert_same Hive::Tui::Messages::TERMINATE_REQUESTED, + Hive::Tui::KeyMap.message_for(mode: :grid, key: "q", row: row) + assert_kind_of Hive::Tui::Messages::OpenTaskFolder, + Hive::Tui::KeyMap.message_for(mode: :grid, key: "o", row: row) + assert_kind_of Hive::Tui::Messages::OpenInAgent, + Hive::Tui::KeyMap.message_for(mode: :grid, key: "s", row: row) + end + def test_log_tail_o_is_noop # Mode isolation R7: in :log_tail mode the `o` key falls through # to NOOP (only q/Esc are bound — back to grid). Pins that @@ -933,7 +960,10 @@ class TuiKeyMapMessageForTest < Minitest::Test [ :log_tail, "q", make_row(action_key: "agent_running") ], [ :log_tail, :key_escape, make_row(action_key: "agent_running") ], [ :filter, :key_escape, nil ], - [ :idea_preview, "x", nil ] + [ :idea_preview, "x", nil ], + [ :idea_preview, "q", nil ], + [ :idea_preview, "i", nil ], + [ :idea_preview, :key_escape, nil ] ] fixtures.each do |mode, key, row| diff --git a/test/unit/tui/model_test.rb b/test/unit/tui/model_test.rb index efcf5716..6c99fd7a 100644 --- a/test/unit/tui/model_test.rb +++ b/test/unit/tui/model_test.rb @@ -26,8 +26,7 @@ class HiveTuiModelTest < Minitest::Test assert_nil model.new_idea_staging_dir assert_nil model.new_idea_staging_tmp_root assert_equal [], model.new_idea_broken_labels - assert_nil model.idea_preview_text - assert_nil model.idea_preview_slug + assert_nil model.info_panel_state assert_nil model.flash assert_nil model.flash_set_at assert_nil model.tail_state @@ -65,17 +64,33 @@ class HiveTuiModelTest < Minitest::Test assert_equal 2, b.scope end - def test_with_updates_idea_preview_fields + def test_with_updates_info_panel_state + state = Hive::Tui::Model::InfoPanelState.new( + slug: "ship-preview", + stage: "2-brainstorm", + idea_text: "original idea" + ) a = Hive::Tui::Model.initial - b = a.with(idea_preview_text: "original idea", idea_preview_slug: "ship-preview") + b = a.with(info_panel_state: state) - assert_nil a.idea_preview_text - assert_nil a.idea_preview_slug - assert_equal "original idea", b.idea_preview_text - assert_equal "ship-preview", b.idea_preview_slug + assert_nil a.info_panel_state + assert_equal "ship-preview", b.info_panel_state.slug + assert_equal "original idea", b.info_panel_state.idea_text refute_same a, b end + def test_info_panel_state_defaults_optional_fields + state = Hive::Tui::Model::InfoPanelState.new(slug: "x", stage: "1-inbox") + + assert_nil state.row + assert_nil state.created_at + assert_nil state.idea_text + assert_nil state.folder_path + assert_nil state.latest_log_path + assert_nil state.extra_title + assert_nil state.extra_body + end + def test_model_is_immutable # Data.define records freeze themselves. Verify reassignment raises. model = Hive::Tui::Model.initial @@ -129,7 +144,7 @@ class HiveTuiModelTest < Minitest::Test expected = %i[mode snapshot cursor filter filter_buffer scope pane_focus new_idea_project_name new_idea_project_cursor new_idea_buffer new_idea_cursor new_idea_attachments new_idea_staging_dir new_idea_staging_tmp_root new_idea_attachment_counter - new_idea_broken_labels idea_preview_text idea_preview_slug flash flash_set_at + new_idea_broken_labels info_panel_state flash flash_set_at tail_state red_status_detail_state cols rows last_error] assert_equal expected, Hive::Tui::Model.members diff --git a/test/unit/tui/update_test.rb b/test/unit/tui/update_test.rb index 53fc23ab..4bd94709 100644 --- a/test/unit/tui/update_test.rb +++ b/test/unit/tui/update_test.rb @@ -1338,24 +1338,28 @@ class HiveTuiUpdateTest < Minitest::Test assert_equal :grid, new_model.mode end - def test_back_from_idea_preview_clears_text_and_returns_to_grid - starting = model.with( - mode: :idea_preview, - idea_preview_text: "original idea", - idea_preview_slug: "some-slug" + def test_back_from_idea_preview_clears_info_panel_state_and_returns_to_grid + state = Hive::Tui::Model::InfoPanelState.new( + slug: "some-slug", + stage: "2-brainstorm", + idea_text: "original idea" ) + starting = model.with(mode: :idea_preview, info_panel_state: state) new_model, _cmd = Hive::Tui::Update.apply(starting, Hive::Tui::Messages::BACK) assert_equal :grid, new_model.mode - assert_nil new_model.idea_preview_text - assert_nil new_model.idea_preview_slug + assert_nil new_model.info_panel_state end def test_back_from_idea_preview_preserves_cursor_and_scope + state = Hive::Tui::Model::InfoPanelState.new( + slug: "some-slug", + stage: "2-brainstorm", + idea_text: "original idea" + ) starting = model.with( mode: :idea_preview, - idea_preview_text: "original idea", - idea_preview_slug: "some-slug", + info_panel_state: state, cursor: [ 1, 2 ], scope: 2 ) @@ -1365,6 +1369,89 @@ class HiveTuiUpdateTest < Minitest::Test assert_equal 2, new_model.scope end + def test_back_from_idea_preview_restores_opened_row_after_snapshot_reorders + opened = red_detail_row.with( + slug: "opened-task", + folder: "/tmp/opened-task", + stage: "2-brainstorm" + ) + other = red_detail_row.with(slug: "other-task", folder: "/tmp/other-task") + state = Hive::Tui::Model::InfoPanelState.new( + row: opened, + slug: opened.slug, + stage: opened.stage, + folder_path: opened.folder + ) + starting = model.with( + mode: :idea_preview, + snapshot: snapshot_with_rows(opened, other), + cursor: [ 0, 0 ], + info_panel_state: state + ) + reordered, _cmd = Hive::Tui::Update.apply( + starting, + Hive::Tui::Messages::SnapshotArrived.new(snapshot: snapshot_with_rows(other, opened)) + ) + + new_model, _cmd = Hive::Tui::Update.apply(reordered, Hive::Tui::Messages::BACK) + + assert_equal :grid, new_model.mode + assert_equal [ 0, 1 ], new_model.cursor + end + + def test_back_from_red_status_detail_restores_opened_row_after_snapshot_reorders + opened = red_detail_row + other = red_detail_row.with(slug: "other-task", folder: "/tmp/other-task") + state = Hive::Tui::Model::RedStatusDetailState.new(row: opened) + starting = model.with( + mode: :red_status_detail, + snapshot: snapshot_with_rows(opened, other), + cursor: [ 0, 0 ], + red_status_detail_state: state + ) + reordered, _cmd = Hive::Tui::Update.apply( + starting, + Hive::Tui::Messages::SnapshotArrived.new(snapshot: snapshot_with_rows(other, opened)) + ) + + new_model, _cmd = Hive::Tui::Update.apply(reordered, Hive::Tui::Messages::BACK) + + assert_equal :grid, new_model.mode + assert_equal [ 0, 1 ], new_model.cursor + end + + def test_back_from_log_tail_restores_opened_row_after_snapshot_reorders + opened = red_detail_row + other = red_detail_row.with(slug: "other-task", folder: "/tmp/other-task") + tail_state = Struct.new(:row).new(opened) + starting = model.with( + mode: :log_tail, + snapshot: snapshot_with_rows(opened, other), + cursor: [ 0, 0 ], + tail_state: tail_state + ) + reordered, _cmd = Hive::Tui::Update.apply( + starting, + Hive::Tui::Messages::SnapshotArrived.new(snapshot: snapshot_with_rows(other, opened)) + ) + + new_model, _cmd = Hive::Tui::Update.apply(reordered, Hive::Tui::Messages::BACK) + + assert_equal :grid, new_model.mode + assert_equal [ 0, 1 ], new_model.cursor + end + + def test_noop_in_idea_preview_leaves_mode_and_state + state = Hive::Tui::Model::InfoPanelState.new(slug: "s", stage: "1-inbox") + starting = model.with(mode: :idea_preview, info_panel_state: state, cursor: [ 0, 3 ]) + new_model, cmd = Hive::Tui::Update.apply(starting, Hive::Tui::Messages::NOOP) + + assert_same starting, new_model + assert_nil cmd + assert_equal :idea_preview, new_model.mode + assert_equal [ 0, 3 ], new_model.cursor + end + def test_project_scope_sets_scope_and_resets_cursor starting = model.with(snapshot: snap_with_two_projects_three_rows_each, cursor: [ 0, 2 ]) new_model, _cmd = Hive::Tui::Update.apply(starting, Hive::Tui::Messages::ProjectScope.new(n: 2)) diff --git a/test/unit/tui/views/idea_preview_test.rb b/test/unit/tui/views/idea_preview_test.rb deleted file mode 100644 index b18b8def..00000000 --- a/test/unit/tui/views/idea_preview_test.rb +++ /dev/null @@ -1,59 +0,0 @@ -require "test_helper" -require "hive/tui/model" -require "hive/tui/views/idea_preview" - -class HiveTuiViewsIdeaPreviewTest < Minitest::Test - include HiveTestHelper - - def model_with(text: "Original idea", slug: "some-slug", cols: 80) - Hive::Tui::Model.initial.with( - mode: :idea_preview, - idea_preview_text: text, - idea_preview_slug: slug, - cols: cols - ) - end - - def render_lines(**kwargs) - Hive::Tui::Views::IdeaPreview.render(model_with(**kwargs), width: kwargs.fetch(:cols, 80)).lines(chomp: true) - end - - def test_renders_header_with_slug - out = Hive::Tui::Views::IdeaPreview.render(model_with(slug: "preview-me")) - assert_includes out, "Idea for preview-me:" - end - - def test_renders_original_text_verbatim - out = Hive::Tui::Views::IdeaPreview.render(model_with(text: "keep [image1] plain")) - assert_includes out, "keep [image1] plain" - end - - def test_renders_dismiss_hint - out = Hive::Tui::Views::IdeaPreview.render(model_with) - assert out.end_with?(Hive::Tui::Views::IdeaPreview::DISMISS_HINT), - "dismiss hint must be the final rendered line" - end - - def test_truncates_long_lines_to_width - lines = render_lines(text: "x" * 80, cols: 20) - - assert lines.all? { |line| line.length <= 20 }, - "all rendered lines must fit width: #{lines.inspect}" - end - - def test_caps_visible_rows_for_oversized_text - text = (1..10).map { |i| "line #{i}" }.join("\n") - lines = render_lines(text: text) - body_lines = lines[1...-1] - - assert_operator body_lines.length, :<=, Hive::Tui::Views::IdeaPreview::MAX_VISIBLE_ROWS - end - - def test_handles_nil_text_gracefully - lines = render_lines(text: nil, slug: "nil-text") - - assert_equal 2, lines.length - assert_includes lines.first, "Idea for nil-text:" - assert_equal Hive::Tui::Views::IdeaPreview::DISMISS_HINT, lines.last - end -end diff --git a/test/unit/tui/views/info_panel_test.rb b/test/unit/tui/views/info_panel_test.rb new file mode 100644 index 00000000..f15fb374 --- /dev/null +++ b/test/unit/tui/views/info_panel_test.rb @@ -0,0 +1,126 @@ +require "test_helper" +require "hive/tui/model" +require "hive/tui/views/info_panel" + +class HiveTuiViewsInfoPanelTest < Minitest::Test + include HiveTestHelper + + def state(**overrides) + Hive::Tui::Model::InfoPanelState.new( + **{ + slug: "ship-preview", + stage: "2-brainstorm", + created_at: "2026-05-20T00:00:00Z", + idea_text: "Original idea text", + folder_path: "/tmp/demo/.hive-state/stages/2-brainstorm/ship-preview", + latest_log_path: "/tmp/demo/.hive-state/logs/ship-preview/run.log", + extra_title: "brainstorm.md", + extra_body: "Brainstorm body" + }.merge(overrides) + ) + end + + def model_with(state_overrides: {}, cols: 80, rows: 24) + Hive::Tui::Model.initial.with( + mode: :idea_preview, + info_panel_state: state(**state_overrides), + cols: cols, + rows: rows + ) + end + + def render_lines(**kwargs) + Hive::Tui::Views::InfoPanel.render(model_with(**kwargs)).lines(chomp: true) + end + + def test_renders_common_field_labels_and_extra_section + out = Hive::Tui::Views::InfoPanel.render(model_with) + assert_includes out, "Info · ship-preview" + assert_includes out, "Slug: ship-preview" + assert_includes out, "Stage: 2-brainstorm" + assert_includes out, "Created: 2026-05-20T00:00:00Z" + assert_includes out, "Idea: Original idea text" + assert_includes out, "Working dir:" + assert_includes out, "Latest log:" + assert_includes out, "brainstorm.md" + assert_includes out, "Brainstorm body" + assert_includes out, Hive::Tui::Views::InfoPanel::CLOSE_HINT + end + + def test_nil_fields_render_unavailable + out = Hive::Tui::Views::InfoPanel.render( + model_with(state_overrides: { + created_at: nil, + idea_text: nil, + folder_path: nil, + latest_log_path: nil, + extra_title: "plan.md", + extra_body: nil + }) + ) + assert_includes out, "Created: (unavailable)" + assert_includes out, "Idea: (unavailable)" + assert_includes out, "Working dir: (unavailable)" + assert_includes out, "Latest log: (unavailable)" + assert_includes out, "plan.md" + assert_includes out, "(unavailable)" + end + + def test_overflow_ends_with_ellipsis_and_fits_frame_height + long_body = (1..80).map { |i| "line #{i}" }.join("\n") + lines = render_lines( + state_overrides: { extra_title: "plan.md", extra_body: long_body }, + cols: 80, + rows: 10 + ) + + assert_operator lines.length, :<=, 10 + assert_equal "…", lines[-2] + assert_equal Hive::Tui::Views::InfoPanel::CLOSE_HINT, lines.last + end + + def test_one_row_frame_renders_only_the_footer + assert_equal [ Hive::Tui::Views::InfoPanel::CLOSE_HINT ], render_lines(rows: 1) + end + + def test_labeled_block_reserves_width_for_the_first_line_prefix + lines = Hive::Tui::Views::InfoPanel.labeled_block( + "Idea", "abcdefghijklmnopqrst", 10 + ) + + assert_equal [ "Idea: abcd", "efghijklmn", "opqrst" ], lines + end + + def test_dynamic_text_is_sanitized_before_wrapping + assert_equal "4-execute", + Hive::Tui::Views::InfoPanel.display("4-execute\e[2J") + assert_equal [ "Idea: alphabeta?end" ], + Hive::Tui::Views::InfoPanel.labeled_block( + "Idea", "alpha\e[2Jbeta\tend", 80 + ) + assert_equal [ "tailsafe?" ], + Hive::Tui::Views::InfoPanel.body_block("tail\e[Hsafe\b", 80) + end + + def test_narrow_cols_truncates_every_line + lines = render_lines(cols: 20, rows: 24) + + assert lines.all? { |line| line.length <= 20 }, + "all rendered lines must fit width: #{lines.inspect}" + end + + def test_inbox_state_omits_extras_heading + out = Hive::Tui::Views::InfoPanel.render( + model_with(state_overrides: { + stage: "1-inbox", + extra_title: nil, + extra_body: nil + }) + ) + + refute_includes out, "brainstorm.md" + refute_includes out, "plan.md" + refute_includes out, "execute log" + assert_includes out, "Stage: 1-inbox" + end +end diff --git a/wiki/commands/tui.md b/wiki/commands/tui.md index f46a0504..1f0d0575 100644 --- a/wiki/commands/tui.md +++ b/wiki/commands/tui.md @@ -3,7 +3,7 @@ title: hive tui type: command source: lib/hive/tui.rb created: 2026-04-27 -updated: 2026-05-22 +updated: 2026-07-17 tags: [command, tui, observability, interactive, diagnostics] --- @@ -28,7 +28,7 @@ The legacy curses backend was removed in plan #003 U11. `HIVE_TUI_BACKEND=curses │ myapp │ ⚠ oauth-… 6-review Needs recovery 1h │ │ appcrawl │ │ ├─────────────────┴────────────────────────────────────────────────────────┤ -│ Footer: [Tab] switch [Enter] action [n] new [/] filter [?] help [q]│ +│ Footer: [Tab] switch [Enter] action [n] new [/] filter [?] help [i] info [q]│ └──────────────────────────────────────────────────────────────────────────┘ ``` @@ -45,6 +45,7 @@ Pane focus is keyboard-only; the focused pane border is bright cyan, the inactiv | Filter prompt | `/` | `Esc` (cancels typed buffer; any committed filter is preserved) / `Enter` (commits) | | New idea project picker | `n` from `★ All projects` scope | `Esc` / `q` (cancels) / `Enter` (selects and advances to title prompt) | | New idea prompt | `n` (single-project scope), or after picker selection (all-projects scope) | `Esc` (cancels) / `Enter` (submits `hive new ""`) | +| Info panel | `i` on a focused task row (right pane) | `q` / `Esc` / `i` (other keys are no-ops; selection preserved) | | Help overlay | `?` | any key | ## Keybindings (default mode) @@ -65,6 +66,7 @@ Pane focus is keyboard-only; the focused pane border is bright cyan, the inactiv | `a` | run `hive archive` | | `Enter` | from left pane: focus right pane. From right pane: perform the row's contextual action: input editor on `needs_input` (completed brainstorm answer rounds auto-run; plan rows auto-advance to `develop` or auto-revise on user feedback), log tail on `agent_running` (and on `error` rows still in a kill-class auto-heal window), red-status detail on selected review-recovery and non-kill-class `error` rows, direct retry/browse for the legacy review-stale exceptions, and suggested-command dispatch for ready rows | | `o` | open the focused row's hive-state task folder in `$VISUAL` / `$EDITOR` / `vi` for read-only browsing — no marker change, no workflow dispatch. Distinct from `Enter` (workflow-contextual) and the verb keys (subprocess dispatch). Useful for revisiting investigation outputs in `9-done` (or any stage). | +| `i` | open a read-only full-screen **info panel** for the focused row (right-pane focus required, same gate as `o`/`s`). Shows slug, stage, `created_at`, idea text (`idea.md` frontmatter `original_text`), working-dir path, latest log path under `<hive-state>/logs/<slug>/`, plus stage extras: `brainstorm.md` body in `2-brainstorm`, `plan.md` body in `3-plan`, execute-log tail in `4-execute`. File reads are byte-bounded and invalid UTF-8 is replaced; the dedicated `idea.md` bound accommodates the full accepted 4,096-character UTF-8 input, while the execute-log read and rendered body remain capped at 8 KiB. Stage-extra paths are read only when the row has a non-empty task folder. Rendered card text is stripped of terminal control sequences. The execute tail is exactly the newest 40 complete lines when available, while a large single-line log still shows its bounded suffix. Missing pieces render as `(unavailable)`; open never aborts. Overflow truncates with a trailing `…` (no scrolling this iteration), including a footer-only frame when the terminal has one row. Close resolves the opened row's identity against the latest snapshot, so 1 Hz polling cannot move selection when rows reorder. Mode symbol remains `:idea_preview` so help-overlay text stays untouched for this change. Close with `q` / `Esc` / `i`. | | `s` | steer the focused task manually: open the configured `execute.agent` in the feature worktree with every existing stage folder for that slug passed as agent context, mark the row `MANUAL_STEERING`, and archive the slug under `archived-manual/` when the agent exits | | `n` | open the new-idea flow; if scope is `★ All projects`, first show a project picker, then submit with `hive new <project> "<title>"` against the chosen concrete project | | `/` | open filter prompt | @@ -195,7 +197,7 @@ Note that `REVIEW_STALE reason=wall_clock` deliberately retries even when no rev - `test/integration/tui_command_test.rb` — Thor help-text registration, `--json` rejection, non-tty boundary check. - `test/unit/tui/*_test.rb` — pure-Ruby state machines (`StateSource`, `Snapshot`, `KeyMap`, `GridState`, `LogTail::FileResolver`, `Help`, `Model`, `Messages`, `Update`, `BubbleModel`). -- `test/unit/tui/views/*_test.rb` — pure-function view tests for every Lipgloss-rendered frame (`ProjectsPane`, `TasksPane`, `LogTail`, `RedStatusDetail`, `HelpOverlay`, `FilterPrompt`, `NewIdeaPrompt`). Layout/text content is pinned; visual styling (color/bold/reverse) is validated by manual dogfood — lipgloss-ruby v0.2.2 strips ANSI in non-tty test environments (gap tracked in `docs/solutions/2026-04-27-charm-bubbletea-api-gaps.md`). Selection / cursor highlight predicates (`ProjectsPane#selected?`, `TasksPane#highlight?`) are exposed for unit-test assertion since the rendered output cannot distinguish them in non-tty. +- `test/unit/tui/views/*_test.rb` — pure-function view tests for every Lipgloss-rendered frame (`ProjectsPane`, `TasksPane`, `LogTail`, `RedStatusDetail`, `InfoPanel`, `HelpOverlay`, `FilterPrompt`, `NewIdeaPrompt`). Layout/text content is pinned; visual styling (color/bold/reverse) is validated by manual dogfood — lipgloss-ruby v0.2.2 strips ANSI in non-tty test environments (gap tracked in `docs/solutions/2026-04-27-charm-bubbletea-api-gaps.md`). Selection / cursor highlight predicates (`ProjectsPane#selected?`, `TasksPane#highlight?`) are exposed for unit-test assertion since the rendered output cannot distinguish them in non-tty. - `test/integration/tui_subprocess_test.rb` — `Subprocess.takeover_command` / `run_quiet!` against a fake child binary. - `test/integration/tui_smoke_test.rb` + `test/integration/tui_smoke_charm_test.rb` — PTY-based boot smokes: `bin/hive tui` paints, the seeded project name appears, `q` exits 0. diff --git a/wiki/log.md b/wiki/log.md index 7c8465bc..148c5678 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2,6 +2,20 @@ Append-only log of all wiki operations. +## [2026-07-17T08:45:53Z] tui — info-panel review hardening + +**Action:** Hardened the full-screen `i` info panel after review. `idea.md` now uses the same bounded, invalid-UTF-8-tolerant read path as stage bodies; the renderer sanitizes all card-derived fields before wrapping; the `Idea: ` prefix has an explicit first-line width budget; a one-row terminal renders only the close footer; and a bounded execute-log window preserves its suffix when a large single-line log offers no later complete line. + +**Refreshed pages:** +- [[commands/tui]] — documented bounded/sanitized reads, single-line execute-tail fallback, and the one-row frame invariant. + +## [2026-07-17T00:00:00Z] tui — full-screen info panel on `i` + legend entry + +**Action:** Upgraded the existing `i` key (previously a bottom-strip `idea.md` original_text preview dismissed by any key) into a read-only full-screen info panel. `Model::InfoPanelState` replaces `idea_preview_text`/`idea_preview_slug`; open-time assembly in `BubbleModel#open_idea_preview` never hard-fails (missing fields → nil → `(unavailable)`). Stage extras: brainstorm.md / plan.md / execute-log tail. Close only via `q`/`Esc`/`i` (`KeyMap#idea_preview_message`). Footer legend now includes `[i] info` between help and quit. Mode symbol stays `:idea_preview` (help-screen content non-goal this iteration). View: `Views::InfoPanel` replaces deleted `Views::IdeaPreview`. + +**Refreshed pages:** +- [[commands/tui]] — footer legend, modes table, keybinding for `i`. + ## [2026-05-23T11:30:00Z] drop — pass-1 + pass-2 review-finding fixes hardened hard-delete **Action:** Recorded the two follow-up fix passes against `hive drop` after the initial feat/U1+U3 commit. Pass-1 (24 findings) and pass-2 (48 findings) tightened idempotency, PID-reuse safety, locale-stable git stderr parsing, worktree-pointer root validation, malformed-YAML rescue in `Worktree.read_pointer`, daemon-row `folder_missing_nil` distinction, and a closed `commit_action` enum on the drop schema. The `9-done` refusal prose in [[commands/drop]] was folded into the refusals-table caption. [[cli]] is already in sync (drop row + exit-code/`--json` envelope row). Schema enum + `holder`/`lock_path` extras were aligned with `DropErrorKind` during pass-1. @@ -1855,3 +1869,10 @@ chruby and RVM are intentionally not handled — they modify PATH per-shell and **Refreshed pages:** - [[testing]] — documented the CI foreground/daemonization coverage pitfall and reload-safe enum caveat. + +## [2026-07-17T09:30:00Z] tui — info-panel bounded-read and selection fixes + +**Action:** Completed the second info-panel review pass. Closing card-derived overlays now resolves the opened row identity against the latest visible snapshot instead of trusting coordinates that polling may have reordered; the shared remedy covers the info panel, red-status detail, and live log tail. `idea.md` has a dedicated bounded-read allowance sized for the full accepted 4,096-character UTF-8 composer input. Empty task-folder paths no longer probe root-level stage artifacts, and execute-log reads retain a hard 8 KiB cap even if the file grows after its size check. The execute-tail regression now pins exactly 40 complete lines and partial-prefix removal. + +**Refreshed pages:** +- [[commands/tui]] — clarified identity restoration, distinct idea/log read bounds, missing-folder behavior, and the exact 40-line execute-tail contract.