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<String> — 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 <project> "<title>"`) |
 | 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.
