diff --git a/lib/hive/tui/bubble_model.rb b/lib/hive/tui/bubble_model.rb index d42249e..556b3ee 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 :info_panel 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 @@ -384,8 +384,8 @@ module Hive open_input_editor(message.row) when Hive::Tui::Messages::OpenTaskFolder open_task_folder(message.row) - when Hive::Tui::Messages::OpenIdeaPreview - open_idea_preview(message.row) + when Hive::Tui::Messages::OpenInfoPanel + open_info_panel(message.row) when Hive::Tui::Messages::OpenInAgent dispatch_open_in_agent_then_close_detail(message.row) when Hive::Tui::Messages::AgentSteerExited @@ -1514,7 +1514,33 @@ module Hive [ flashed("editor command invalid: #{e.message}"), nil ] end - def open_idea_preview(row) + # Stage-specific extras policy for the info panel (`i`). Stages + # absent from this table render common fields only. The 4-execute + # special case is handled separately because its artifact is the + # formatted tail of the resolved execute log, not a named file. + INFO_PANEL_EXTRA_FILES = { + "2-brainstorm" => [ "brainstorm.md", "Brainstorm (brainstorm.md)" ], + "3-plan" => [ "plan.md", "Plan (plan.md)" ] + }.freeze + # Bounded reads so a huge stage file / slow disk cannot wedge the + # keystroke-time snapshot (plan R3): extras content is capped + # before it lands on the frozen state, and the execute-log tail + # uses the same ring-buffer window shape as the red-status detail. + INFO_PANEL_EXTRA_MAX_BYTES = 32 * 1024 + INFO_PANEL_EXTRA_MAX_LINES = 4096 + INFO_PANEL_LOG_TAIL_LINES = 50 + INFO_PANEL_SNAPSHOT_BACKBUFFER_BYTES = 64 * 1024 + + # Read-only snapshot open for the grid-mode `i` gesture. Captures + # all panel content synchronously at keystroke time into a frozen + # InfoPanelState (mirroring RedStatusDetail's stale-but-fast + # trade); performs no writes, no subprocess dispatch, no marker + # mutation. Hard guards preserved from the retired bottom-strip + # preview: missing folder / missing idea.md / empty original_text + # refuse with a flash and stay in grid. Ancillary artifacts that + # are merely not written yet never refuse the open — they degrade + # to placeholders rendered by the view. + def open_info_panel(row) return [ flashed("no idea for #{row.slug}"), nil ] if row.folder.to_s.empty? idea_path = File.join(row.folder, "idea.md") @@ -1526,12 +1552,21 @@ module Hive return [ flashed("idea has no original_text for #{row.slug}"), nil ] end + created_at = data["created_at"].to_s capped_text = original_text[0, Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS] + extra_title, extra_lines = resolve_info_panel_extras(row) [ @hive_model.with( - mode: :idea_preview, - idea_preview_text: capped_text, - idea_preview_slug: row.slug + mode: :info_panel, + info_panel_state: Hive::Tui::Model::InfoPanelState.new( + row: row, + created_at: created_at.empty? ? "(unknown)" : created_at, + idea_text: capped_text, + dir_path: File.expand_path(row.folder.to_s), + log_path: resolve_info_panel_log_path(row)&.to_s, + extra_title: extra_title, + extra_lines: extra_lines + ) ), nil ] @@ -1539,6 +1574,95 @@ module Hive [ flashed("could not read idea for #{row.slug}"), nil ] end + # Latest-log resolution mirrors the red-status detail snapshot: + # `Task#log_dir` first, then a stage-local `logs/` dir. Contract + # difference from `open_log_tail`: a nil result must NOT flash or + # close anything — the log path here is informational. + def resolve_info_panel_log_path(row) + task = Hive::Task.new(row.folder.to_s) + Hive::Tui::LogTail::FileResolver.latest_in_dirs( + [ task.log_dir, File.join(task.folder, "logs") ] + ) + rescue Hive::NoLogFiles, Hive::InvalidTaskPath, *Hive::Tui::LogTail::FILESYSTEM_RESCUE => e + Hive::Tui::Debug.log( + "info_panel", + "log path skipped slug=#{row&.slug} err=#{e.class.name.split('::').last}: #{e.message}" + ) + nil + end + + # Stage-extra policy resolution, evaluated ONCE per open so the + # execute-log path resolves/tails a single consistent file. + # Returns [title, lines]: + # * stages without a policy (1-inbox, 5-open-pr … 9-done) → + # [nil, []] — the view omits the section entirely; + # * 2-brainstorm / 3-plan → fixed title + raw lines of the stage + # artifact ([] when not written yet — never refuses); + # * 4-execute → title derived from the resolved log basename plus + # the formatted bounded tail; omitted entirely when no log + # exists yet. + def resolve_info_panel_extras(row) + if row.stage == "4-execute" + snapshot = resolve_execute_log_tail(row) + return [ nil, [].freeze ] unless snapshot + + return [ "Latest execute log (#{File.basename(snapshot[:path])})", snapshot[:lines] ] + end + + entry = INFO_PANEL_EXTRA_FILES[row.stage] + return [ nil, [].freeze ] unless entry + + file_name, title = entry + [ title, read_capped_extras_lines(File.join(row.folder.to_s, file_name)) ] + end + + # Last ≤ INFO_PANEL_LOG_TAIL_LINES lines of the most recent execute + # log, formatted through LogTail::Formatter like every other log + # surface. Ring capacity/backbuffer window mirrors + # red_status_detail_log_snapshot so both keystroke-time snapshots + # share one bounded-latency design (plan R3). + def resolve_execute_log_tail(row) + return nil if row&.folder.to_s.empty? + + task = Hive::Task.new(row.folder.to_s) + log_path = Hive::Tui::LogTail::FileResolver.latest_in_dirs( + [ task.log_dir, File.join(task.folder, "logs") ] + ) + tail = Hive::Tui::LogTail::Tail.new( + log_path, + ring_capacity: INFO_PANEL_LOG_TAIL_LINES, + backbuffer_bytes: INFO_PANEL_SNAPSHOT_BACKBUFFER_BYTES + ) + tail.open! + { + path: log_path, + lines: Hive::Tui::LogTail::Formatter.format_lines(tail.lines(INFO_PANEL_LOG_TAIL_LINES)).freeze + } + rescue Hive::NoLogFiles, Hive::InvalidTaskPath, *Hive::Tui::LogTail::FILESYSTEM_RESCUE => e + Hive::Tui::Debug.log( + "info_panel", + "execute tail skipped slug=#{row&.slug} err=#{e.class.name.split('::').last}: #{e.message}" + ) + nil + ensure + tail&.close! + end + + def read_capped_extras_lines(path) + return [].freeze unless File.file?(path) + + File.read(path, INFO_PANEL_EXTRA_MAX_BYTES) + .lines(chomp: true) + .first(INFO_PANEL_EXTRA_MAX_LINES) + .map { |line| Hive::Tui::Text.sanitize(line) } + .freeze + rescue Errno::ENOENT, Errno::EACCES + # Concurrent-writer race between the existence probe and the + # read (R4): degrade to an empty extras block rather than tear + # down an otherwise-valid snapshot. + [].freeze + end + def idea_frontmatter(contents) match = contents.match(/\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\z)/m) return {} unless match @@ -2884,11 +3008,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 +3121,10 @@ 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 fixed hint string doesn't overflow narrow terminals + # (e.g. cols=70 used to wrap onto a second visible row; the hint + # is 79 chars since the locked `[i] info` entry landed — see R2's + # accepted narrow-terminal clipping trade-off). def default_footer(usable_width = nil) if @hive_model.flash_active? line = @hive_model.flash.to_s @@ -3017,7 +3138,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 8233e9a..0446a2c 100644 --- a/lib/hive/tui/key_map.rb +++ b/lib/hive/tui/key_map.rb @@ -88,7 +88,7 @@ module Hive when :red_status_detail then red_status_detail_message(key: key, row: row) when :filter then filter_message(key: key, row: row) when :help then help_message(key: key, row: row) - when :idea_preview then idea_preview_message(key: key, row: row) + when :info_panel then info_panel_message(key: key, row: row) when :new_idea_project then new_idea_project_message(key: key, row: row) when :new_idea then new_idea_message(key: key, row: row) else raise ArgumentError, "unknown mode: #{mode.inspect}" @@ -135,7 +135,7 @@ module Hive # could fire any of these against a row whose cursor they are # not visually tracking. return Messages::OpenTaskFolder.new(row: row) if key == "o" && pane_focus == :right - return Messages::OpenIdeaPreview.new(row: row) if key == "i" && pane_focus == :right + return Messages::OpenInfoPanel.new(row: row) if key == "i" && pane_focus == :right return Messages::OpenInAgent.new(row: row) if key == "s" && pane_focus == :right return verb_message(row, key) if VERB_KEYS.key?(key) return enter_message(row) if ENTER_KEYS.include?(key) @@ -402,11 +402,17 @@ 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. - def idea_preview_message(key:, row:) # rubocop:disable Lint/UnusedMethodArgument - Messages::BACK + # Info panel is read-only with a tight close contract (locked + # requirements A5/A7): exactly Esc / q / i return to grid — `i` + # doubles as a toggle so re-pressing it dismisses without arrowing + # anywhere first. Everything else is an explicit NOOP: verb + # letters, j/k navigation, filter chars, Enter and Tab must never + # navigate or mutate from inside the modal. This deliberately + # tightens away the retired preview's dismiss-on-any-key behavior. + def info_panel_message(key:, row:) # rubocop:disable Lint/UnusedMethodArgument + return Messages::BACK if ESCAPE_KEYS.include?(key) || key == "q" || key == "i" + + Messages::NOOP end # New-idea prompt mode — same key shape as `:filter` mode but diff --git a/lib/hive/tui/messages.rb b/lib/hive/tui/messages.rb index 980952a..089920f 100644 --- a/lib/hive/tui/messages.rb +++ b/lib/hive/tui/messages.rb @@ -179,12 +179,14 @@ module Hive # (workflow-contextual) and the verb keys (subprocess dispatch). OpenTaskFolder = Data.define(:row) - # `i` in grid mode — read the focused row's source idea.md and - # show its original_text in the bottom strip. Carries the row so - # BubbleModel's side-effect handler can resolve `row.folder` at - # the moment of the keystroke; this cannot be a payload-free - # singleton because snapshot polling may move the live cursor. - OpenIdeaPreview = Data.define(:row) + # `i` in grid mode — open the read-only info panel for the focused + # row (full-screen modal over the grid: common idea.md fields, + # stage-specific extras). Carries the row so BubbleModel's + # side-effect handler can resolve `row.folder` at the moment of + # the keystroke; this cannot be a payload-free singleton because + # snapshot polling may move the live cursor before the panel's + # snapshot is taken. + OpenInfoPanel = Data.define(:row) # `s` in grid mode — suspend the TUI and open the focused row's # configured development agent in the feature worktree. BubbleModel diff --git a/lib/hive/tui/model.rb b/lib/hive/tui/model.rb index b81a44c..213171a 100644 --- a/lib/hive/tui/model.rb +++ b/lib/hive/tui/model.rb @@ -20,7 +20,7 @@ module Hive # Lifted out of the Data.define block because Ruby's Data.define # block-scope doesn't bind constants to the resulting class. Model = Data.define( - :mode, # Symbol: :grid / :log_tail / :filter / :help / :new_idea_project / :new_idea / :idea_preview / :red_status_detail + :mode, # Symbol: :grid / :log_tail / :filter / :help / :new_idea_project / :new_idea / :info_panel / :red_status_detail :snapshot, # Hive::Tui::Snapshot (or nil before first poll) :cursor, # [project_idx, row_idx] (or nil for empty grid) :filter, # String or nil — committed substring filter @@ -40,8 +40,7 @@ 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 + :info_panel_state, # Model::InfoPanelState or nil — :info_panel mode only (snapshot captured at open) :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 +103,36 @@ module Hive end end + # Read-only filesystem snapshot shown by `Views::InfoPanel` in + # :info_panel mode. Built once per open in + # `BubbleModel#open_info_panel` so the render path stays disk-free; + # fields: + # - row: snapshot row captured when the panel opened + # - created_at: idea.md frontmatter created_at, normalized to a + # display string by the resolver + # - idea_text: original_text capped at NEW_IDEA_BUFFER_MAX_CHARS + # - dir_path: absolute working-dir path of the task folder + # - log_path: absolute latest-log path (nil → view renders + # `(no logs yet)`); informational only — never + # flash-closes the panel + # - extra_title: stage-extra section title (nil → omit section, + # e.g. 1-inbox) + # - extra_lines: frozen array of raw lines for that artifact + Model::InfoPanelState = Data.define( + :row, + :created_at, + :idea_text, + :dir_path, + :log_path, + :extra_title, + :extra_lines + ) + class Model::InfoPanelState + def initialize(row:, idea_text:, dir_path:, created_at: nil, log_path: nil, extra_title: nil, extra_lines: []) + 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 +157,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 f17a433..9047d44 100644 --- a/lib/hive/tui/update.rb +++ b/lib/hive/tui/update.rb @@ -782,7 +782,7 @@ end 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) + when :info_panel then model.with(mode: :grid, info_panel_state: nil) when :help, :filter then model.with(mode: :grid) when :new_idea_project then apply_new_idea_cancelled(model) else model diff --git a/lib/hive/tui/views/idea_preview.rb b/lib/hive/tui/views/idea_preview.rb deleted file mode 100644 index c9fdfb0..0000000 --- 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 0000000..be2e6cc --- /dev/null +++ b/lib/hive/tui/views/info_panel.rb @@ -0,0 +1,153 @@ +require "lipgloss" +require "hive/tui/styles" +require "hive/tui/views/format" + +module Hive + module Tui + module Views + # Full-screen read-only info panel for a task card, opened by the + # grid-mode `i` gesture. Renders the filesystem snapshot captured + # once at open time on `Hive::Tui::Model::InfoPanelState` — the + # view itself never touches disk (same snapshot-at-open contract + # as `Views::RedStatusDetail`). Close keys live in KeyMap + # (:info_panel closes on Esc / q / i); this module only renders. + # + # Body layout: + # * Fields block — slug / stage / created / working dir / + # latest log path (nil → `(no logs yet)` placeholder). + # * `Original idea` header + width-wrapped original idea text. + # * Conditional extras section (brainstorm.md / plan.md contents + # or the execute-log tail) titled by the resolver; empty extras + # render `(not written yet)`. Stages without an extras policy + # carry `extra_title: nil` and omit the section entirely. + # Overflow is height-truncated behind a single `… (N more lines not + # shown)` hint — no scrolling inside the panel by design. + module InfoPanel + CLOSE_HINT = "q/Esc/i close".freeze + NO_LOGS_PLACEHOLDER = "(no logs yet)".freeze + NO_EXTRAS_PLACEHOLDER = "(not written yet)".freeze + CREATED_UNKNOWN_LABEL = "(unknown)".freeze + MORE_LINES_HINT = "… (%d more lines not shown)".freeze + + module_function + + def render(model) + state = model.info_panel_state + return "" if state.nil? + + header_width = [ model.cols.to_i - 1, 1 ].max + outer_width = [ model.cols.to_i - 2, 1 ].max + bordered = model.cols.to_i >= 40 + inner_width = bordered ? [ outer_width - 2, 1 ].max : outer_width + body_height = [ model.rows.to_i - 5, 1 ].max + footer_rows = footer_lines(inner_width) + inner_body_height = [ body_height - footer_rows.size - 1, 1 ].max + + visible = clamp_with_more_hint(body_lines(state, inner_width), inner_body_height, inner_width) + visible.concat([ "" ], footer_rows) + body = Lipgloss.join_vertical(Lipgloss::TOP, *visible) + panel = bordered ? Styles::PANE_FOCUSED_BORDER.width(inner_width).render(body) : body + + Lipgloss.join_vertical(Lipgloss::TOP, header_bar(state.row, header_width), panel) + end + + # @api private — exposed for tests. + def body_lines(state, width) + rows = [ + field_line("Slug:", safe(state.row.slug), width), + field_line("Stage:", safe(state.row.stage), width), + field_line("Created:", safe(created_label(state)), width), + field_line("Working dir:", safe(state.dir_path), width), + field_line("Latest log:", safe(log_label(state)), width) + ] + append_idea(rows, state, width) + append_extras(rows, state, width) + rows + end + + def clamp_with_more_hint(rows, budget, width) + return rows if budget < 1 || rows.length <= budget + + hidden = rows.length - [ budget - 1, 0 ].max + clamped = rows.first([ budget - 1, 0 ].max) + clamped + [ Styles::HINT.render(truncate(format(MORE_LINES_HINT, hidden), width)) ] + end + + def append_idea(rows, state, width) + rows << "" + rows << Styles::HEADER.render(truncate("Original idea", width)) + wrap_text(state.idea_text.to_s, width).each { |line| rows << line } + end + + def append_extras(rows, state, width) + title = state.extra_title.to_s + return if title.empty? + + rows << "" + rows << Styles::HEADER.render(truncate(title, width)) + lines = Array(state.extra_lines) + if lines.empty? + rows << truncate(NO_EXTRAS_PLACEHOLDER, width) + else + lines.each { |line| rows << truncate(line, width) } + end + end + + def field_line(label, value, width) + truncate("#{label} #{value}", width) + end + + def footer_lines(width) + [ Styles::HINT.render(truncate(CLOSE_HINT, width)) ] + end + + def header_bar(row, width) + line = "#{safe(row.slug)} · #{safe(row.stage)}" + Styles::RECOVERY_HEADER_STYLE.render(truncate(line, width)) + end + + def created_label(state) + text = state.created_at.to_s + text.empty? ? CREATED_UNKNOWN_LABEL : text + end + + def log_label(state) + return NO_LOGS_PLACEHOLDER if state.log_path.nil? + + state.log_path.to_s + end + + # Intentional local copy of NewIdeaPrompt's simple chunking shape: + # hard chunks each source line at `width` cells. No markdown + # rendering exists in the TUI today — stage files are displayed as + # raw plain-text lines. + def chunk_line(line, capacity) + return [ "" ] if line.empty? + + chunks = [] + offset = 0 + while offset < line.length + chunks << line[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_line(line, capacity) + end + end + + def safe(value) + Hive::Tui::Text.sanitize(value.to_s) + 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 bfc3e63..0633d27 100644 --- a/test/unit/tui/bubble_model_test.rb +++ b/test/unit/tui/bubble_model_test.rb @@ -345,17 +345,25 @@ 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_info_panel_mode_full_screen + row = make_task_row(folder: "/tmp/hive/some-slug") + state = Hive::Tui::Model::InfoPanelState.new( + row: row, + created_at: "2026-05-20T00:00:00Z", + idea_text: "original idea", + dir_path: "/tmp/hive/some-slug" + ) @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" + mode: :info_panel, + info_panel_state: state, + cols: 100, + rows: 24 ), dispatch: @dispatch ) out = @model.view - assert_includes out, "Idea for some-slug:" + assert_includes out, "some-slug" assert_includes out, "original idea" end @@ -603,28 +611,24 @@ 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_default_footer_hint_carries_i_info_entry + # Locked requirement A1: the legend permanently shows `[i] info` + # between `[?] help` and `[q] quit`. Pinned as a whole-string + # equality so accidental spacing/label drift fails loudly. hint = @model.send(:footer_hint) - assert_equal "[Tab] switch [Enter] action [n] new [/] filter [?] help [q] quit", + assert_equal "[Tab] switch [Enter] action [n] new [/] filter [?] help [i] info [q] quit", hint, - "footer hint must remain the pre-`o` literal; `o` is documented in `?` only" + "footer hint must keep the locked entry ordering with [i] info between help and quit" refute_includes hint, "[o] open", - "70-col budget can't absorb `[o] open` alongside primary hints" + "`o` stays documented in `?` only — no second browse hint in the legend" # 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" + # who adds another hint acknowledges they're spending bytes against + # the default-footer budget. The `i`-info growth past the historical + # 70-col fit is mandated by the locked A1 requirement (plan R2): + # below ~80 cols the trailing `[q] quit` truncates via + # Views::Format.truncate instead of wrapping to a second row. + assert_operator hint.length, :<=, 80, + "footer hint must stay within the default-footer budget; got #{hint.length} chars" end def test_grid_mode_collapses_to_single_pane_below_min_cols @@ -3658,37 +3662,40 @@ class HiveTuiBubbleModelTest < Minitest::Test "OpenTaskFolder must not dispatch any follow-up message — no auto-continue, no InputEditorExited" end - # ---- OpenIdeaPreview → bottom-strip preview (read-only) ---- + # ---- OpenInfoPanel → full-screen read-only info panel ---- - def test_open_idea_preview_reads_original_text_and_enters_preview_mode + def test_open_info_panel_captures_snapshot_and_enters_panel_mode 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") - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.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 + assert_equal :info_panel, @model.hive_model.mode + state = @model.hive_model.info_panel_state + refute_nil state + assert_equal "Build task from user note", state.idea_text + assert_same row, state.row + assert_equal File.expand_path(dir), state.dir_path end end - def test_open_idea_preview_flashes_when_folder_empty + def test_open_info_panel_flashes_when_folder_empty row = make_task_row(folder: "") - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.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) end - def test_open_idea_preview_flashes_when_idea_md_missing + def test_open_info_panel_flashes_when_idea_md_missing with_tmp_dir do |dir| row = make_task_row(folder: dir) - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) assert_nil cmd assert_equal :grid, @model.hive_model.mode @@ -3696,12 +3703,12 @@ class HiveTuiBubbleModelTest < Minitest::Test end end - def test_open_idea_preview_flashes_when_original_text_missing + def test_open_info_panel_flashes_when_original_text_missing with_tmp_dir do |dir| File.write(File.join(dir, "idea.md"), "---\nslug: some-slug\n---\n") row = make_task_row(folder: dir) - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) assert_nil cmd assert_equal :grid, @model.hive_model.mode @@ -3709,12 +3716,12 @@ class HiveTuiBubbleModelTest < Minitest::Test end end - def test_open_idea_preview_flashes_on_unreadable_idea_md + def test_open_info_panel_flashes_on_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)) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) assert_nil cmd assert_equal :grid, @model.hive_model.mode @@ -3722,13 +3729,13 @@ class HiveTuiBubbleModelTest < Minitest::Test end end - def test_open_idea_preview_does_not_dispatch_or_mutate_marker + def test_open_info_panel_does_not_dispatch_or_mutate_marker with_tmp_dir do |dir| idea_path = write_idea_md(dir, original_text: "Read only") before = File.read(idea_path) row = make_task_row(folder: dir) - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) assert_nil cmd assert_empty @messages @@ -3736,42 +3743,208 @@ class HiveTuiBubbleModelTest < Minitest::Test end end - def test_open_idea_preview_truncates_oversized_original_text + def test_open_info_panel_truncates_oversized_original_text with_tmp_dir do |dir| original = "x" * (Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS + 20) write_idea_md(dir, original_text: original) row = make_task_row(folder: dir) - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) assert_nil cmd - assert_equal :idea_preview, @model.hive_model.mode + assert_equal :info_panel, @model.hive_model.mode assert_equal Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS, - @model.hive_model.idea_preview_text.length + @model.hive_model.info_panel_state.idea_text.length end end - def test_idea_preview_roundtrip_open_then_any_key_dismisses + # Locked A5: three fresh-open cycles, each closed by a different + # contracted key (q, Esc, i); after every close the previously + # selected card stays selected (cursor tuple untouched by apply_back). + def test_info_panel_close_cycle_q_escape_and_i_each_return_to_grid with_tmp_dir do |dir| write_idea_md(dir, original_text: "Roundtrip idea") row = make_task_row(folder: dir) - _, open_cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + %w[q i].each_with_index do |close_key, cycle| + _, open_cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + assert_nil open_cmd + assert_equal :info_panel, @model.hive_model.mode, "cycle #{close_key.inspect}: must open fresh" + assert_equal "Roundtrip idea", @model.hive_model.info_panel_state.idea_text + + keystroke = Bubbletea::KeyMessage.new(key_type: Bubbletea::KeyMessage::KEY_RUNES, runes: [ close_key.ord ]) + _, dismiss_cmd = @model.update(keystroke) + + assert_nil dismiss_cmd + assert_equal :grid, @model.hive_model.mode, + "cycle #{cycle + 1}: #{close_key.inspect} must return to the grid" + assert_nil @model.hive_model.info_panel_state, + "cycle #{cycle + 1}: panel snapshot must be cleared on close" + assert_empty @messages + end + + # Escape closes via the special-key path. + @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + assert_equal :info_panel, @model.hive_model.mode + @model.update(Bubbletea::KeyMessage.new(key_type: Bubbletea::KeyMessage::KEY_ESC)) + assert_equal :grid, @model.hive_model.mode, "Esc must return to the grid" + assert_nil @model.hive_model.info_panel_state + end + end + + # ---- U3 resolver fixtures (per-stage extras + log resolution) ---- + + # Builds a task folder that satisfies Hive::Task's PATH_RE so the + # resolver can derive log_dir/log dirs exactly like production. + def with_hive_task_dir(stage:, slug: "panel-task") + with_tmp_dir do |project_root| + folder = File.join(project_root, ".hive-state", "stages", stage, slug) + FileUtils.mkdir_p(folder) + row = make_task_row( + stage: stage, slug: slug, folder: folder, + state_file: File.join(folder, "idea.md"), + suggested_command: "hive brainstorm #{slug} --from #{stage}" + ) + yield(project_root, folder, row) + end + end + + def write_hive_idea_md(folder, original_text:, created_at: nil) + frontmatter = [ "---", + "slug: panel-task", + ("created_at: #{created_at}" if created_at), + "original_text: |", + *original_text.lines.map { |line| " #{line.chomp}" } ].compact + File.write(File.join(folder, "idea.md"), (frontmatter + [ "---", "", "# body", original_text ]).join("\n")) + end + + def test_open_info_panel_captures_brainstorm_extras_and_created_at + with_hive_task_dir(stage: "2-brainstorm") do |_root, folder, row| + write_hive_idea_md(folder, original_text: "Build it", created_at: "2026-05-20T00:00:00Z") + File.write(File.join(folder, "brainstorm.md"), "# Round 1\n\n## Brainstorm output\n") + + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + + assert_nil cmd + state = @model.hive_model.info_panel_state + refute_nil state + assert_equal :info_panel, @model.hive_model.mode + # created_at may parse as a YAML Time (permitted class) or stay a + # hand-written string; R5 normalizes via to_s so either shape renders. + assert_includes state.created_at, "2026-05-20" + assert_equal "Brainstorm (brainstorm.md)", state.extra_title + assert_includes state.extra_lines.join("\n"), "Round 1" + end + end + + def test_open_info_panel_captures_plan_extras + with_hive_task_dir(stage: "3-plan") do |_root, folder, row| + write_hive_idea_md(folder, original_text: "Plan it") + File.write(File.join(folder, "plan.md"), "# Plan body line one\n") + + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + + assert_nil cmd + state = @model.hive_model.info_panel_state + assert_equal "Plan (plan.md)", state.extra_title + assert_includes state.extra_lines.join("\n"), "Plan body line one" + end + end - assert_nil open_cmd - assert_equal :idea_preview, @model.hive_model.mode - assert_equal "Roundtrip idea", @model.hive_model.idea_preview_text + def test_open_info_panel_captures_execute_log_tail_for_4_execute + with_hive_task_dir(stage: "4-execute") do |_root, folder, row| + write_hive_idea_md(folder, original_text: "Run it") + logs_dir = File.join(folder, "logs") + FileUtils.mkdir_p(logs_dir) + # HIVE-formatted entry so the assertion also proves formatting ran. + File.write(File.join(logs_dir, "execute-1.log"), + "[hive] 2026-05-24T10:00:00Z stage=execute started\nplain passthrough line\n") - _, dismiss_cmd = @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "x".ord ])) + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) - assert_nil dismiss_cmd + assert_nil cmd + state = @model.hive_model.info_panel_state + assert_equal "Latest execute log (execute-1.log)", state.extra_title + joined = state.extra_lines.join("\n") + assert_includes joined, "hive stage=execute started" + assert_includes joined, "plain passthrough line" + assert_operator state.extra_lines.length, :<=, + Hive::Tui::BubbleModel::INFO_PANEL_LOG_TAIL_LINES + # The execute tail resolves through the same directory pair as + # the common "Latest log" field — both point at the newest file. + assert_equal File.join(logs_dir, "execute-1.log"), state.log_path + end + end + + def test_open_info_panel_carries_no_extras_for_stageless_policy_rows + [ "1-inbox", "5-open-pr", "9-done" ].each do |stage| + with_hive_task_dir(stage: stage) do |_root, folder, row| + write_hive_idea_md(folder, original_text: "Just common fields") + + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + + assert_nil cmd + state = @model.hive_model.info_panel_state + assert_nil state.extra_title, "#{stage} must omit the extras section" + assert_empty state.extra_lines + end + end + end + + def test_open_info_panel_degrades_when_extra_artifact_not_written_yet + with_hive_task_dir(stage: "2-brainstorm") do |_root, folder, row| + write_hive_idea_md(folder, original_text: "Early brainstorm card") + + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + + assert_nil cmd + assert_equal :info_panel, @model.hive_model.mode, "a missing ancillary artifact must never refuse the open" + state = @model.hive_model.info_panel_state + assert_equal "Brainstorm (brainstorm.md)", state.extra_title + assert_empty state.extra_lines + end + end + + def test_open_info_panel_still_opens_with_no_log_files_and_unknown_created_at + with_hive_task_dir(stage: "2-brainstorm") do |_root, folder, row| + write_hive_idea_md(folder, original_text: "No logs, no timestamp") + + _, cmd = @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + + assert_nil cmd + assert_equal :info_panel, @model.hive_model.mode + state = @model.hive_model.info_panel_state + assert_nil state.log_path, "zero log files must resolve to an informational nil, not a refusal" + assert_equal "(unknown)", state.created_at + end + end + + def test_open_info_panel_cycle_leaves_fixture_files_untouched + with_hive_task_dir(stage: "2-brainstorm") do |_root, folder, row| + write_hive_idea_md(folder, original_text: "Read only A6") + File.write(File.join(folder, "brainstorm.md"), "# Round 1\n") + logs_dir = File.join(folder, "logs") + FileUtils.mkdir_p(logs_dir) + File.write(File.join(logs_dir, "execute-1.log"), "[hive] 2026-05-24T10:00:00Z stage=execute\n") + + before = fixture_fingerprint(folder) + @model.update(Hive::Tui::Messages::OpenInfoPanel.new(row: row)) + assert_equal :info_panel, @model.hive_model.mode + @model.update(Hive::Tui::Messages::BACK) assert_equal :grid, @model.hive_model.mode - assert_nil @model.hive_model.idea_preview_text - assert_nil @model.hive_model.idea_preview_slug - assert_empty @messages + + assert_equal before, fixture_fingerprint(folder), + "open+close must not mutate any file mtime/content under the task folder" end end + def fixture_fingerprint(root) + Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH) + .select { |path| File.file?(path) } + .sort + .to_h { |path| [ path, [ File.mtime(path).to_f, Digest::SHA256.file(path).hexdigest ] ] } + end + # ---- OpenInAgent → configured agent foreground takeover ---- def test_open_in_agent_marks_manual_steering_and_spawns_in_worktree_with_context_dirs diff --git a/test/unit/tui/key_map_test.rb b/test/unit/tui/key_map_test.rb index d58b283..c8a4f6e 100644 --- a/test/unit/tui/key_map_test.rb +++ b/test/unit/tui/key_map_test.rb @@ -155,11 +155,11 @@ class TuiKeyMapMessageForTest < Minitest::Test assert_same Hive::Tui::Messages::NOOP, msg end - def test_grid_i_with_row_returns_open_idea_preview + def test_grid_i_with_row_returns_open_info_panel row = make_row(action_key: "ready_to_plan") msg = Hive::Tui::KeyMap.message_for(mode: :grid, key: "i", row: row) - assert_kind_of Hive::Tui::Messages::OpenIdeaPreview, msg + assert_kind_of Hive::Tui::Messages::OpenInfoPanel, msg assert_equal row, msg.row end @@ -258,10 +258,38 @@ 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| - 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" + # Locked A5 contract: exactly q / Esc / i close the info panel. + def test_info_panel_q_closes + msg = Hive::Tui::KeyMap.message_for(mode: :info_panel, key: "q", row: nil) + assert_same Hive::Tui::Messages::BACK, msg + end + + def test_info_panel_escape_closes + msg = Hive::Tui::KeyMap.message_for(mode: :info_panel, key: :key_escape, row: nil) + assert_same Hive::Tui::Messages::BACK, msg + end + + def test_info_panel_i_toggles_closed + msg = Hive::Tui::KeyMap.message_for(mode: :info_panel, key: "i", row: nil) + assert_same Hive::Tui::Messages::BACK, msg + end + + # Locked A7 contract: every other keystroke is a NOOP — including + # navigation, verbs, filter chars and special keys — so typing inside + # the read-only panel can neither navigate nor close it. + def test_info_panel_non_close_keys_are_inert + [ "j", "k", "?", "/", "x", "b", "z", :key_enter, :key_tab, :space ].each do |noop_key| + msg = Hive::Tui::KeyMap.message_for(mode: :info_panel, key: noop_key, row: nil) + assert_same Hive::Tui::Messages::NOOP, msg, "#{noop_key.inspect} must be inert inside the info panel" + end + end + + def test_info_panel_verb_letter_is_noop_not_dispatch + # Verbs must never fire from the modal read-only surface. + row = make_row(action_key: "ready_to_plan") + [ "b", "p", "d", "r", "a" ].each do |verb_key| + msg = Hive::Tui::KeyMap.message_for(mode: :info_panel, key: verb_key, row: row) + assert_same Hive::Tui::Messages::NOOP, msg, "verb #{verb_key.inspect} must not dispatch from the info panel" end end @@ -933,7 +961,7 @@ 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 ] + [ :info_panel, "x", nil ] ] fixtures.each do |mode, key, row| diff --git a/test/unit/tui/messages_test.rb b/test/unit/tui/messages_test.rb index 322dce5..065b010 100644 --- a/test/unit/tui/messages_test.rb +++ b/test/unit/tui/messages_test.rb @@ -59,12 +59,12 @@ class HiveTuiMessagesTest < Minitest::Test assert_same row, msg.row end - def test_open_idea_preview_carries_row + def test_open_info_panel_carries_row row = Object.new - msg = Hive::Tui::Messages::OpenIdeaPreview.new(row: row) + msg = Hive::Tui::Messages::OpenInfoPanel.new(row: row) assert_same row, msg.row - assert_includes Hive::Tui::Messages::OpenIdeaPreview.members, :row + assert_includes Hive::Tui::Messages::OpenInfoPanel.members, :row end def test_open_in_agent_carries_row diff --git a/test/unit/tui/model_test.rb b/test/unit/tui/model_test.rb index efcf571..29652b1 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,14 +64,16 @@ class HiveTuiModelTest < Minitest::Test assert_equal 2, b.scope end - def test_with_updates_idea_preview_fields + def test_with_updates_info_panel_state_field a = Hive::Tui::Model.initial - b = a.with(idea_preview_text: "original idea", idea_preview_slug: "ship-preview") + state = Hive::Tui::Model::InfoPanelState.new( + row: Object.new, created_at: "2026-05-20", idea_text: "original idea", + dir_path: "/tmp/hive/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_same state, b.info_panel_state refute_same a, b end @@ -129,7 +130,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 53fc23a..229775b 100644 --- a/test/unit/tui/update_test.rb +++ b/test/unit/tui/update_test.rb @@ -1338,24 +1338,27 @@ class HiveTuiUpdateTest < Minitest::Test assert_equal :grid, new_model.mode end - def test_back_from_idea_preview_clears_text_and_returns_to_grid + def test_back_from_info_panel_clears_state_and_returns_to_grid starting = model.with( - mode: :idea_preview, - idea_preview_text: "original idea", - idea_preview_slug: "some-slug" + mode: :info_panel, + info_panel_state: Hive::Tui::Model::InfoPanelState.new( + row: Object.new, created_at: "2026-05-20", + idea_text: "original idea", dir_path: "/tmp/hive/some-slug" + ) ) 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 + def test_back_from_info_panel_preserves_cursor_and_scope starting = model.with( - mode: :idea_preview, - idea_preview_text: "original idea", - idea_preview_slug: "some-slug", + mode: :info_panel, + info_panel_state: Hive::Tui::Model::InfoPanelState.new( + row: Object.new, created_at: "2026-05-20", + idea_text: "original idea", dir_path: "/tmp/hive/some-slug" + ), cursor: [ 1, 2 ], scope: 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 b18b8de..0000000 --- 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 0000000..a85d33e --- /dev/null +++ b/test/unit/tui/views/info_panel_test.rb @@ -0,0 +1,154 @@ +require "test_helper" +require "hive/tui/model" +require "hive/tui/snapshot" +require "hive/tui/views/info_panel" + +class HiveTuiViewsInfoPanelTest < Minitest::Test + def row(stage: "2-brainstorm", slug: "panel-task", folder: "/tmp/demo/.hive-state/stages/2-brainstorm/panel-task") + Hive::Tui::Snapshot::Row.new( + project_name: "demo", stage: stage, slug: slug, folder: folder, + state_file: File.join(folder, "idea.md"), marker: "waiting", + attrs: nil, mtime: nil, age_seconds: 0, claude_pid: nil, + claude_pid_alive: nil, action_key: "ready_to_brainstorm", + action_label: "Ready to brainstorm", suggested_command: nil, + next_action: nil, diagnostic: nil + ) + end + + def state(extra_lines: [], extra_title: nil, **overrides) + defaults = { + row: row, + created_at: "2026-05-20T00:00:00Z", + idea_text: "Add an [i] info legend entry and a full-screen info panel.", + dir_path: "/tmp/demo/.hive-state/stages/2-brainstorm/panel-task", + log_path: nil, + extra_title: extra_title, + extra_lines: extra_lines + }.merge(overrides) + Hive::Tui::Model::InfoPanelState.new(**defaults) + end + + def model_with(state_obj = state, cols: 100, rows: 30) + Hive::Tui::Model.initial.with(mode: :info_panel, info_panel_state: state_obj, cols: cols, rows: rows) + end + + def render_lines(model = model_with) + Hive::Tui::Views::InfoPanel.render(model).lines(chomp: true) + end + + # A2 / common-fields pin: header bar + every field label render. + def test_renders_common_fields_and_header + out = Hive::Tui::Views::InfoPanel.render(model_with) + + assert_includes out, "panel-task · 2-brainstorm" + assert_includes out, "Slug: panel-task" + assert_includes out, "Stage: 2-brainstorm" + assert_includes out, "Created: 2026-05-20T00:00:00Z" + assert_includes out, "Working dir: /tmp/demo/.hive-state/stages/2-brainstorm/panel-task" + end + + # A6-degrade: missing log resolution is informational only. + def test_renders_no_logs_placeholder_when_log_path_is_nil + out = Hive::Tui::Views::InfoPanel.render(model_with) + + assert_includes out, "Latest log: (no logs yet)" + end + + def test_renders_absolute_log_path_when_resolved + log_state = state(log_path: "/tmp/demo/.hive-state/logs/panel-task/execute.log") + out = Hive::Tui::Views::InfoPanel.render(model_with(log_state)) + + assert_includes out, "Latest log: /tmp/demo/.hive-state/logs/panel-task/execute.log" + end + + # A4: stages without an extras policy omit the section entirely — no + # header and no "(not written yet)" stub. + def test_omits_extras_section_for_stages_without_policy + out = Hive::Tui::Views::InfoPanel.render(model_with) + + refute_includes out, "(not written yet)" + refute_match(/Brainstorm \(brainstorm\.md\)/, out) + end + + # A2: extras section renders its title and raw file lines. + def test_renders_extras_section_with_lines + brainstorm_state = state( + extra_title: "Brainstorm (brainstorm.md)", + extra_lines: [ "# Round 1", "Q1: scope?" ] + ) + lines = render_lines(model_with(brainstorm_state)) + joined = lines.join("\n") + + assert_match(/Brainstorm \(brainstorm\.md\)/, joined) + assert_includes joined, "# Round 1" + assert_includes joined, "Q1: scope?" + end + + def test_renders_not_written_yet_stub_for_empty_extras + plan_state = state(extra_title: "Plan (plan.md)", extra_lines: []) + out = Hive::Tui::Views::InfoPanel.render(model_with(plan_state)) + + assert_match(/Plan \(plan\.md\)/, out) + assert_includes out, Hive::Tui::Views::InfoPanel::NO_EXTRAS_PLACEHOLDER + end + + def test_wraps_long_original_text_at_width + wide_state = state(idea_text: "x" * 120) + model = model_with(wide_state, cols: 60, rows: 40) + outer_width = [ 60 - 2, 1 ].max + inner_width = [ outer_width - 2, 1 ].max + + # Strip ANSI then box-drawing glyphs so wrapped idea chunks surface + # as bare runs of `x` regardless of the bordered-frame context. + chunks = render_lines(model) + .map { |line| strip_ansi(line) } + .map { |line| line.delete("│╭╮╰╯─") } + .grep(/\Ax+\s*\z/) + + assert_operator chunks.length, :>, 1, "long text must wrap onto multiple lines" + assert_operator chunks.map(&:strip).map(&:length).max, :<=, inner_width, + "every wrapped chunk must fit the body width" + end + + def test_height_overflow_appends_more_lines_hint + tall_state = state( + idea_text: (1..80).map { |i| "idea line #{i}" }.join("\n") + ) + lines = render_lines(model_with(tall_state, cols: 100, rows: 24)) + hint_count = lines.count { |line| line.include?("more lines not shown") } + + assert_equal 1, hint_count, "exactly one truncation hint must render, got lines:\n#{lines.inspect}" + end + + def test_no_hint_when_content_fits + short_state = state(idea_text: "short") + lines = render_lines(model_with(short_state, cols: 100, rows: 60)) + + refute(lines.any? { |line| line.include?("more lines not shown") }, + "no truncation hint when everything fits") + end + + def test_nil_state_renders_empty_string + model = Hive::Tui::Model.initial.with(mode: :info_panel, info_panel_state: nil, cols: 100, rows: 30) + + assert_equal "", Hive::Tui::Views::InfoPanel.render(model) + end + + def test_short_terminal_renders_unbordered_without_raising + out = Hive::Tui::Views::InfoPanel.render(model_with(state, cols: 36, rows: 12)) + + refute_includes out, "\e[51m", "sanity: output must be a plain string without box-drawing style escapes under 40 cols" + assert_includes out, "Slug:" + end + + def test_footer_close_hint_rendered_inside_body + lines = render_lines(model_with) + + assert lines.any? { |line| strip_ansi(line).include?(Hive::Tui::Views::InfoPanel::CLOSE_HINT) }, + "close hint strip must be part of the panel frame" + end + + def strip_ansi(text) + text.gsub(/\e\[[0-9;]*m/, "") + end +end diff --git a/wiki/commands/tui.md b/wiki/commands/tui.md index f46a050..cf0f574 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-08-26 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] quit │ └──────────────────────────────────────────────────────────────────────────┘ ``` @@ -43,6 +43,7 @@ Pane focus is keyboard-only; the focused pane border is bright cyan, the inactiv | Agent log tail | `Enter` on an `agent_running` row | `q` / `Esc` | | Input editor | `Enter` on a `needs_input` row | editor exit; completed brainstorm answers auto-continue; plan rows auto-advance to `develop` (or auto-revise if user added feedback) | | Filter prompt | `/` | `Esc` (cancels typed buffer; any committed filter is preserved) / `Enter` (commits) | +| Info panel | `i` on a selected task (right-pane focus) | `q` / `Esc` / `i` (all other keys are inert) | | 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 ""`) | | Help overlay | `?` | any key | @@ -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 the read-only info panel for the focused task — a full-screen modal inside the TUI showing the idea's common fields (`slug`, `stage`, `created_at`, the full original idea text), the absolute working-dir path under `.hive-state/stages/<stage>/<slug>/`, and the absolute latest-log path under `.hive-state/logs/<slug>/`, plus stage-specific extras. No shell-out, no pager, no scrolling; closes with `q`, `Esc`, or `i` again (see below). | | `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 | @@ -147,6 +149,19 @@ Three stages get an auto-continue convenience after the editor exits cleanly: Partial brainstorm answers, stale rows whose marker changed while the editor was open, and any other stage's `:waiting` state stay manual; workflow verb keys (`b` / `p` / `d` / `r` / `P`) remain the explicit rerun path. Note: partial `[x]` ticks on `fix-guardrail-NN.md` and edits that truncate findings (changing the marker's `matches` count) are rejected by [[stages/review]]'s `fix_guardrail_approved?` and keep the pause — the TUI may still dispatch, but the runner re-fires the same `:review_waiting reason=fix_guardrail` marker. +## Info panel mode + +The grid-mode `i` gesture (right-pane focus required, same gate as `o` and `s`) opens a full-screen read-only info panel instead of the retired bottom-strip idea preview. `KeyMap` emits `Messages::OpenInfoPanel` (still row-carrying so the snapshot-cursor race documented there stays avoided), `BubbleModel#open_info_panel` performs ONE bounded synchronous filesystem read at keystroke time — mirroring the red-status detail's stale-but-fast snapshot design — and stores a frozen `Model::InfoPanelState`; `Update.apply_back` closes it. No marker writes, no workflow dispatch, no shell-out, no pager. + +Content: + +- **Common fields:** `slug`, `stage`, `created_at` (from `idea.md` frontmatter; `(unknown)` when absent), the full original idea text capped at the composer's 4 KiB cap, the absolute working-dir path under `.hive-state/stages/<stage>/<slug>/`, and the absolute latest-log path under `.hive-state/logs/<slug>/` (resolved via `LogTail::FileResolver.latest_in_dirs` over `Task#log_dir` and a stage-local `logs/` dir; renders `(no logs yet)` when there are zero log files). A missing log never flash-closes the panel — it is informational here, unlike `open_log_tail`. +- **Stage extras** (`frozen` policy table beside the resolver): `2-brainstorm` renders `brainstorm.md` contents under `Brainstorm (brainstorm.md)`, `3-plan` renders `plan.md` under `Plan (plan.md)`; a not-yet-written artifact renders `(not written yet)` but never refuses the open. `4-execute` shows the last ≤50 lines of the most recent execute log through `LogTail::Formatter.format_lines`. All other stages (`1-inbox`, `5-open-pr` … `9-done`) omit the section entirely. +- **Hard guards preserved from the preview era:** no folder, no `idea.md`, or empty `original_text` refuses with a flash and stays on the grid. +- **Close keys:** exactly `q`, `Esc`, or `i` again return to the grid; every other keystroke is an explicit NOOP (typing cannot navigate or mutate anything), and the previously selected card remains selected because `apply_back` only clears the panel state. +- **No scrolling:** overflow is height-truncated behind a single `… (N more lines not shown)` hint; markdown stage files render as raw width-wrapped plain-text lines (the TUI has no markdown renderer). +- Extras reads are bounded (≤ 32 KiB / 4096 lines; execute-log tail via a ring-buffer window like the red-detail snapshot) so slow disks / NFS can't wedge keystrokes, and concurrent-writer races degrade to short content rather than crashes. + ## Red-status detail mode Red rows still show the concrete marker details in the grid status column, but selected rows now open a full-screen Q&A detail view before clearing anything. The view renders `row.diagnostic` from `hive status --json`: @@ -195,7 +210,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/gaps.md b/wiki/gaps.md index 7421812..77025d6 100644 --- a/wiki/gaps.md +++ b/wiki/gaps.md @@ -3,7 +3,7 @@ title: Gaps type: gaps source: wiki/* vs lib/, templates/, test/ created: 2026-04-25 -updated: 2026-05-22 +updated: 2026-08-26 tags: [gap, todo] --- @@ -49,6 +49,7 @@ tags: [gap, todo] 6. **E2E surface matrix** — `bin/hive-e2e run` is green locally on Linux with tmux 3.6a, but the follow-up matrix across macOS and a different tmux minor version is still open. 7. ~~**Asciinema local verification**~~ — closed 2026-04-30. `/usr/bin/asciinema` 3.2.0 is visible on this shell's PATH, and a smoke run created an asciicast v2 file. `HIVE_ASCIINEMA_BIN=/absolute/path/to/asciinema` remains the fallback for installs outside PATH. 8. **R2 misdiagnosis artifact validation** — e2e artifacts exist, but the "fresh agent course-corrects from a wrong first diagnosis" case needs the first organic failure or a third-party synthetic failure. +9. **Stale `?` help-overlay copy after the info-panel rename (2026-08-26)** — the grid-`i` row in `lib/hive/tui/help.rb` still describes a bottom-strip preview, and its `:idea_preview / any key dismisses` row (plus the `Views::HelpOverlay::MODE_HEADERS` label) references a mode that no longer exists after [[commands/tui]] gained the full-screen info panel. Functionally harmless (the overlay data never reads live `model.mode`; the stale rows still render static text), but cosmetically wrong. Locked non-goals froze the overlay content for that change; rewriting these two entries is the top follow-up candidate for the next TUI task. ## Release install follow-ups diff --git a/wiki/log.md b/wiki/log.md index 7c8465b..4cbac80 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1855,3 +1855,11 @@ 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-08-26T00:00:00Z] commands/tui — info panel + legend refresh + +**Action:** Upgraded the TUI `i` gesture into a full-screen read-only info panel (renamed from the retired bottom-strip idea preview). Grid-mode `i` still requires right-pane focus and now opens a modal rendering the idea's common fields (`slug`, `stage`, `created_at`, full original text), the absolute task working dir, the latest-log path, plus stage-specific extras: `2-brainstorm` → `brainstorm.md`, `3-plan` → `plan.md`, `4-execute` → formatted tail of the newest execute log; other stages omit the section. Close keys tightened to exactly `q` / `Esc` / `i`; all other keys are NOOPs. The default footer legend permanently gained `[i] info` between `[?] help` and `[q] quit`. Data is snapshotted once at keystroke time into a frozen `Model::InfoPanelState` — no marker writes, no subprocess dispatch, no file mutation. + +**Refreshed pages:** +- [[commands/tui]] — modes table, keybindings table, Layout footer sketch, new Info-panel-mode prose section, view-test list. +- [[gaps]] — recorded the intentionally-stale `?` overlay copy (help.rb/help_overlay.rb still describe the preview) as a scoped-out follow-up.