diff --git a/lib/hive/tui/bubble_model.rb b/lib/hive/tui/bubble_model.rb
index d42249e3..d7ad0041 100644
--- a/lib/hive/tui/bubble_model.rb
+++ b/lib/hive/tui/bubble_model.rb
@@ -3,6 +3,7 @@ require "date"
 require "digest"
 require "fileutils"
 require "set"
+require "securerandom"
 require "shellwords"
 require "stringio"
 require "time"
@@ -22,6 +23,7 @@ require "hive/tui/update"
 require "hive/tui/snapshot"
 require "hive/tui/text"
 require "hive/tui/log_tail"
+require "hive/tui/task_info"
 require "hive/tui/brainstorm_answers"
 require "hive/tui/clipboard"
 require "hive/tui/composer_staging"
@@ -32,7 +34,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/task_info"
 require "hive/tui/views/new_idea_prompt"
 require "hive/tui/views/new_idea_project_picker"
 
@@ -103,11 +105,13 @@ module Hive
       def initialize(
         hive_model: Hive::Tui::Model.initial,
         dispatch: ->(_msg) { },
-        clipboard_probe: ->(pasted_text:) { Hive::Tui::Clipboard.probe(pasted_text: pasted_text) }
+        clipboard_probe: ->(pasted_text:) { Hive::Tui::Clipboard.probe(pasted_text: pasted_text) },
+        task_info_loader: ->(folder) { Hive::Tui::TaskInfo.load(folder) }
       )
         @hive_model = hive_model
         @dispatch = dispatch
         @clipboard_probe = clipboard_probe
+        @task_info_loader = task_info_loader
         # `@healed_folders` is touched from the main runner thread
         # (`auto_heal_kill_class_errors` registers folders before
         # spawning heals) AND from heal Threads (which evict on
@@ -139,8 +143,13 @@ module Hive
         # first pass settles. Reuses @healed_folders_mutex so all
         # background-thread bookkeeping fields (@healed_folders,
         # @heal_threads, @review_recovery_inflight,
-        # @error_recovery_inflight) share a single lock.
+        # @error_recovery_inflight, @task_info_thread) share a single
+        # lock.
         @error_recovery_inflight = Set.new
+        # At most one task-info loader may be active. Closing the panel
+        # cancels it immediately; reopening defensively cancels any
+        # superseded worker that has not yet self-pruned.
+        @task_info_thread = nil
         # Once-per-session latch (reset in #stage_image on success).
         @clipboard_tool_hint_shown = false
         # Consecutive `wl-paste`/`xclip` timeouts: a single timeout
@@ -227,7 +236,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 :task_info then Views::TaskInfo.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 +393,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::OpenTaskInfo
+          open_task_info(message)
         when Hive::Tui::Messages::OpenInAgent
           dispatch_open_in_agent_then_close_detail(message.row)
         when Hive::Tui::Messages::AgentSteerExited
@@ -400,6 +409,7 @@ module Hive
           # return nil so Update.apply still handles the mode flip
           # and tail_state clearing.
           close_tail_if_log_tail
+          cancel_task_info_thread if @hive_model.mode == :task_info
           nil
         when Hive::Tui::Messages::NewIdeaSubmitted
           submit_new_idea
@@ -1514,42 +1524,58 @@ module Hive
         [ flashed("editor command invalid: #{e.message}"), nil ]
       end
 
-      def open_idea_preview(row)
-        return [ flashed("no idea for #{row.slug}"), nil ] if row.folder.to_s.empty?
+      def open_task_info(message)
+        request_id = message.request_id || SecureRandom.uuid
+        open_message = Hive::Tui::Messages::OpenTaskInfo.new(row: message.row, request_id: request_id)
+        loading_model, = Hive::Tui::Update.apply(@hive_model, open_message)
+        unless cancel_task_info_thread
+          failed = Hive::Tui::Messages::TaskInfoFailed.new(
+            request_id: request_id,
+            error: "previous task info load is still stopping"
+          )
+          failed_model, = Hive::Tui::Update.apply(loading_model, failed)
+          return [ failed_model, nil ]
+        end
 
-        idea_path = File.join(row.folder, "idea.md")
-        return [ flashed("no idea.md for #{row.slug}"), nil ] unless File.exist?(idea_path)
+        spawn_task_info_thread(message.row, request_id)
+        [ loading_model, nil ]
+      end
 
-        data = idea_frontmatter(File.read(idea_path))
-        original_text = data["original_text"].to_s
-        if original_text.empty?
-          return [ flashed("idea has no original_text for #{row.slug}"), nil ]
+      def spawn_task_info_thread(row, request_id)
+        start_gate = Queue.new
+        thread = Thread.new do
+          start_gate.pop
+          info = @task_info_loader.call(row.folder)
+          @dispatch.call(Hive::Tui::Messages::TaskInfoLoaded.new(request_id: request_id, info: info))
+        rescue StandardError => e
+          text = Hive::Tui::Text.sanitize("#{e.class.name.split('::').last}: #{e.message}")[0, 160]
+          @dispatch.call(Hive::Tui::Messages::TaskInfoFailed.new(request_id: request_id, error: text))
+        ensure
+          @healed_folders_mutex.synchronize do
+            @heal_threads.delete(Thread.current)
+            @task_info_thread = nil if @task_info_thread == Thread.current
+          end
         end
-
-        capped_text = original_text[0, Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS]
-        [
-          @hive_model.with(
-            mode: :idea_preview,
-            idea_preview_text: capped_text,
-            idea_preview_slug: row.slug
-          ),
-          nil
-        ]
-      rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
-        [ flashed("could not read idea for #{row.slug}"), nil ]
+        @healed_folders_mutex.synchronize do
+          @heal_threads << thread
+          @task_info_thread = thread
+        end
+        start_gate << true
+        thread
       end
 
-      def idea_frontmatter(contents)
-        match = contents.match(/\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\z)/m)
-        return {} unless match
+      def cancel_task_info_thread
+        thread = @healed_folders_mutex.synchronize { @task_info_thread }
+        return true unless thread&.alive?
 
-        parsed = YAML.safe_load(
-          match[1],
-          permitted_classes: [ Time, Date ],
-          permitted_symbols: [],
-          aliases: false
-        ) || {}
-        parsed.is_a?(Hash) ? parsed : {}
+        thread.kill
+        begin
+          thread.join(KILL_GRACE_SECONDS)
+        rescue StandardError
+          # A dead runner can make the worker's final dispatch raise.
+          # Dismissal still wins; the thread's ensure already pruned it.
+        end
+        !thread.alive?
       end
 
       def resolve_agent_label(row)
@@ -2884,11 +2910,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,7 +3023,7 @@ 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
+      # so the fixed 79-char hint string doesn't overflow narrow
       # terminals (e.g. cols=70 used to wrap onto a second visible row).
       def default_footer(usable_width = nil)
         if @hive_model.flash_active?
@@ -3017,7 +3038,7 @@ module Hive
       end
 
       def footer_hint
-        "[Tab] switch  [Enter] action  [n] new  [/] filter  [?] help  [q] quit"
+        "[Tab] switch  [Enter] action  [n] new  [/] filter  [?] help  [i] info  [q] quit"
       end
 
       # Compute pane widths and join horizontally. Left pane is clamped
diff --git a/lib/hive/tui/key_map.rb b/lib/hive/tui/key_map.rb
index 8233e9a4..a05c56ad 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 :task_info then task_info_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::OpenTaskInfo.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,12 @@ 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
+      # Task info is deliberately modal and read-only. Only the three
+      # documented close gestures leave it; all other input is ignored.
+      def task_info_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/log_tail.rb b/lib/hive/tui/log_tail.rb
index a997fd5d..57b6e6c2 100644
--- a/lib/hive/tui/log_tail.rb
+++ b/lib/hive/tui/log_tail.rb
@@ -154,13 +154,13 @@ module Hive
         # is empty so the render-mode boundary can short-circuit
         # back to grid with a flash message instead of opening an
         # empty viewer.
-        def latest(log_dir)
-          latest_in_dirs([ log_dir ])
+        def latest(log_dir, pattern: "*.log")
+          latest_in_dirs([ log_dir ], pattern: pattern)
         end
 
-        def latest_in_dirs(log_dirs)
+        def latest_in_dirs(log_dirs, pattern: "*.log")
           dirs = Array(log_dirs)
-          candidates = dirs.flat_map { |log_dir| Dir[File.join(log_dir.to_s, "*.log")] }
+          candidates = dirs.flat_map { |log_dir| Dir[File.join(log_dir.to_s, pattern)] }
           raise Hive::NoLogFiles, "no log files in #{dirs.join(', ')}" if candidates.empty?
 
           # `File.mtime` can race with concurrent log rotation that removes a
@@ -168,7 +168,7 @@ module Hive
           # let Errno::ENOENT crash the TUI.
           with_mtimes = candidates.filter_map do |path|
             [ path, File.mtime(path) ]
-          rescue Errno::ENOENT
+          rescue *FILESYSTEM_RESCUE
             nil
           end
           raise Hive::NoLogFiles, "no log files in #{dirs.join(', ')}" if with_mtimes.empty?
diff --git a/lib/hive/tui/messages.rb b/lib/hive/tui/messages.rb
index 980952a5..bdba230c 100644
--- a/lib/hive/tui/messages.rb
+++ b/lib/hive/tui/messages.rb
@@ -179,12 +179,21 @@ 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 full-screen task information
+      # panel. KeyMap leaves request_id nil; BubbleModel assigns a unique
+      # identity before Update enters loading state and starts filesystem
+      # I/O. Carries the selected row so cursor movement or snapshot
+      # refreshes cannot retarget an in-flight request.
+      OpenTaskInfo = Data.define(:row, :request_id) do
+        def initialize(row:, request_id: nil)
+          super
+        end
+      end
+
+      # Immutable result messages dispatched by the tracked loader worker.
+      # Update accepts them only while the matching request is still open.
+      TaskInfoLoaded = Data.define(:request_id, :info)
+      TaskInfoFailed = Data.define(:request_id, :error)
 
       # `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 b81a44c0..7965e98f 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 selecting the active grid, prompt, or full-screen view
       :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
+      :task_info_state, # Model::TaskInfoState or nil — :task_info mode only
       :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
@@ -93,6 +92,11 @@ module Hive
       :log_lines,
       :log_scroll_offset
     )
+    Model::TaskInfoState = Data.define(:row, :request_id, :status, :info, :error) do
+      def initialize(row:, request_id:, status: :loading, info: nil, error: nil)
+        super
+      end
+    end
     # Single source of truth for the "no resolved label" copy. The
     # resolver in BubbleModel falls back to this string when project
     # config / agent lookup fails, so the view can render `agent_label`
@@ -128,8 +132,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,
+          task_info_state: nil,
           flash: nil,
           flash_set_at: nil,
           tail_state: nil,
diff --git a/lib/hive/tui/task_info.rb b/lib/hive/tui/task_info.rb
new file mode 100644
index 00000000..ae13c5da
--- /dev/null
+++ b/lib/hive/tui/task_info.rb
@@ -0,0 +1,182 @@
+require "date"
+require "time"
+require "yaml"
+require "hive/task"
+require "hive/tui/log_tail"
+
+module Hive
+  module Tui
+    # Read-only filesystem snapshot used by the full-screen task info view.
+    # All reads happen on BubbleModel's tracked worker; Update and rendering
+    # only receive the frozen Snapshot value.
+    module TaskInfo
+      UNAVAILABLE = "unavailable".freeze
+      NONE = "none".freeze
+      EXECUTE_TAIL_LINES = 200
+
+      class LoadError < Hive::Error; end
+
+      Snapshot = Data.define(
+        :slug,
+        :stage,
+        :created_at,
+        :original_text,
+        :task_folder,
+        :latest_log_path,
+        :extra_label,
+        :extra_content,
+        :warnings,
+        :source_paths
+      ) do
+        def initialize(**attributes)
+          attributes = attributes.transform_values do |value|
+            case value
+            when String then value.dup.freeze
+            when Array then value.map { |item| item.is_a?(String) ? item.dup.freeze : item }.freeze
+            else value
+            end
+          end
+          super(**attributes)
+        end
+      end
+
+      module_function
+
+      def load(folder)
+        task = Hive::Task.new(folder)
+        ensure_current_folder!(task)
+        warnings = []
+        source_paths = []
+        idea = load_idea(task, warnings, source_paths)
+        latest_log_path = resolve_latest_log(task.log_dir, warnings)
+        source_paths << latest_log_path unless latest_log_path == NONE
+        extra_label, extra_content = load_extra(task, warnings, source_paths)
+        ensure_current_folder!(task)
+
+        Snapshot.new(
+          slug: task.slug,
+          stage: "#{task.stage_index}-#{task.stage_name}",
+          created_at: idea.fetch(:created_at),
+          original_text: idea.fetch(:original_text),
+          task_folder: task.folder,
+          latest_log_path: latest_log_path,
+          extra_label: extra_label,
+          extra_content: extra_content,
+          warnings: warnings,
+          source_paths: source_paths.uniq
+        )
+      rescue Hive::InvalidTaskPath => e
+        raise LoadError, "invalid task path: #{e.message}"
+      rescue StandardError => e
+        raise if e.is_a?(LoadError)
+
+        raise LoadError, "#{e.class.name.split('::').last}: #{e.message}"
+      end
+
+      def ensure_current_folder!(task)
+        return if File.directory?(task.folder)
+
+        raise LoadError, "task moved or disappeared while loading: #{task.folder}"
+      end
+
+      def load_idea(task, warnings, source_paths)
+        path = File.join(task.folder, "idea.md")
+        contents = read_optional(path, warnings, "idea")
+        return { created_at: UNAVAILABLE, original_text: UNAVAILABLE } if contents.nil?
+
+        source_paths << path
+        frontmatter, body = split_frontmatter(contents)
+        data = parse_frontmatter(frontmatter, warnings)
+        original = data["original_text"].to_s
+        original = body.to_s if original.empty?
+
+        {
+          created_at: format_created_at(data["created_at"]),
+          original_text: original.empty? ? UNAVAILABLE : original
+        }
+      end
+
+      def split_frontmatter(contents)
+        match = contents.match(/\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\z)/m)
+        return [ nil, contents ] unless match
+
+        [ match[1], contents[match.end(0)..].to_s ]
+      end
+
+      def parse_frontmatter(frontmatter, warnings)
+        return {} if frontmatter.nil?
+
+        parsed = YAML.safe_load(
+          frontmatter,
+          permitted_classes: [ Time, Date ],
+          permitted_symbols: [],
+          aliases: false
+        ) || {}
+        parsed.is_a?(Hash) ? parsed : {}
+      rescue Psych::Exception
+        warnings << "malformed idea frontmatter"
+        {}
+      end
+
+      def format_created_at(value)
+        return UNAVAILABLE if value.nil? || value.to_s.empty?
+        return value.utc.iso8601 if value.respond_to?(:utc) && value.respond_to?(:iso8601)
+
+        value.to_s
+      end
+
+      def resolve_latest_log(log_dir, warnings)
+        File.expand_path(LogTail::FileResolver.latest(log_dir))
+      rescue Hive::NoLogFiles
+        NONE
+      rescue *LogTail::FILESYSTEM_RESCUE, IOError => e
+        warnings << "latest log unavailable: #{e.class.name.split('::').last}"
+        NONE
+      end
+
+      def load_extra(task, warnings, source_paths)
+        case task.stage_name
+        when "brainstorm"
+          [ "Brainstorm", load_artifact(File.join(task.folder, "brainstorm.md"), warnings, source_paths) ]
+        when "plan"
+          [ "Plan", load_artifact(File.join(task.folder, "plan.md"), warnings, source_paths) ]
+        when "execute"
+          [ "Execute log", load_execute_tail(task.log_dir, warnings, source_paths) ]
+        else
+          [ nil, nil ]
+        end
+      end
+
+      def load_artifact(path, warnings, source_paths)
+        contents = read_optional(path, warnings, File.basename(path))
+        return UNAVAILABLE if contents.nil?
+
+        source_paths << path
+        contents
+      end
+
+      def load_execute_tail(log_dir, warnings, source_paths)
+        path = LogTail::FileResolver.latest(log_dir, pattern: "execute-*.log")
+        source_paths << File.expand_path(path)
+        tail = LogTail::Tail.new(path, ring_capacity: EXECUTE_TAIL_LINES)
+        tail.open!
+        tail.lines(EXECUTE_TAIL_LINES).join("\n")
+      rescue Hive::NoLogFiles
+        UNAVAILABLE
+      rescue *LogTail::FILESYSTEM_RESCUE, IOError => e
+        warnings << "execute log unavailable: #{e.class.name.split('::').last}"
+        UNAVAILABLE
+      ensure
+        tail&.close!
+      end
+
+      def read_optional(path, warnings, label)
+        contents = File.binread(path)
+        contents.force_encoding(Encoding::UTF_8).scrub("?")
+      rescue *LogTail::FILESYSTEM_RESCUE, IOError => e
+        warnings << "#{label} unavailable: #{e.class.name.split('::').last}"
+        nil
+      end
+    end
+  end
+end
diff --git a/lib/hive/tui/text.rb b/lib/hive/tui/text.rb
index 44b2c791..6d35b8ff 100644
--- a/lib/hive/tui/text.rb
+++ b/lib/hive/tui/text.rb
@@ -25,14 +25,22 @@ module Hive
 
       module_function
 
-      # Idempotent: strip ANSI CSI sequences first (so the trailing
-      # bytes do not survive into the second pass), then replace each
-      # remaining control byte with `?` so column-width math stays
-      # one-cell-per-character. nil/non-string input returns the empty
-      # string — every caller is concerned with display safety, not
-      # with surfacing a TypeError on a missing field.
+      # Idempotent: normalize invalid/undefined input bytes to UTF-8,
+      # strip ANSI CSI sequences (so their trailing bytes do not
+      # survive into the control pass), then replace each remaining
+      # control byte with `?` so column-width math stays one-cell-per-
+      # character. nil/non-string input returns the empty string —
+      # every caller is concerned with display safety, not with
+      # surfacing a TypeError on a missing field.
       def sanitize(text)
-        text.to_s.gsub(ANSI_CSI_PATTERN, "").gsub(CONTROL_CHARS_PATTERN, "?")
+        source = text.to_s.dup
+        if source.encoding == Encoding::ASCII_8BIT
+          source.force_encoding(Encoding::UTF_8)
+        else
+          source = source.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
+        end
+        source = source.scrub("?")
+        source.gsub(ANSI_CSI_PATTERN, "").gsub(CONTROL_CHARS_PATTERN, "?")
       end
     end
   end
diff --git a/lib/hive/tui/update.rb b/lib/hive/tui/update.rb
index f17a433b..1478f86b 100644
--- a/lib/hive/tui/update.rb
+++ b/lib/hive/tui/update.rb
@@ -75,6 +75,12 @@ module Hive
           [ apply_open_filter_prompt(model), nil ]
         when Messages::OpenRedStatusDetail
           [ apply_open_red_status_detail(model, message), nil ]
+        when Messages::OpenTaskInfo
+          [ apply_open_task_info(model, message), nil ]
+        when Messages::TaskInfoLoaded
+          [ apply_task_info_loaded(model, message), nil ]
+        when Messages::TaskInfoFailed
+          [ apply_task_info_failed(model, message), nil ]
         when Messages::RedStatusDetailScroll
           [ apply_red_status_detail_scroll(model, message), nil ]
         when Messages::Back
@@ -713,6 +719,33 @@ module Hive
         model.with(mode: :red_status_detail, red_status_detail_state: state)
       end
 
+      def apply_open_task_info(model, msg)
+        state = Model::TaskInfoState.new(row: msg.row, request_id: msg.request_id)
+        model.with(mode: :task_info, task_info_state: state)
+      end
+
+      def apply_task_info_loaded(model, msg)
+        state = matching_task_info_state(model, msg.request_id)
+        return model if state.nil?
+
+        model.with(task_info_state: state.with(status: :loaded, info: msg.info, error: nil))
+      end
+
+      def apply_task_info_failed(model, msg)
+        state = matching_task_info_state(model, msg.request_id)
+        return model if state.nil?
+
+        model.with(task_info_state: state.with(status: :failed, info: nil, error: msg.error.to_s))
+      end
+
+      def matching_task_info_state(model, request_id)
+        state = model.task_info_state
+        return nil unless model.mode == :task_info && state
+        return nil unless state.request_id == request_id
+
+        state
+      end
+
       def apply_red_status_detail_scroll(model, msg)
         state = model.red_status_detail_state
         if model.mode != :red_status_detail || state.nil?
@@ -782,7 +815,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 :task_info then close_task_info(model)
         when :help, :filter then model.with(mode: :grid)
         when :new_idea_project then apply_new_idea_cancelled(model)
         else model
@@ -793,6 +826,34 @@ end
         Array(model.snapshot&.projects).select { |project| project.error.nil? }
       end
 
+      def close_task_info(model)
+        selected_row = model.task_info_state&.row
+        closed = model.with(mode: :grid, task_info_state: nil)
+        visible = visible_snapshot(closed)
+        return closed if visible.nil?
+
+        selected_cursor = cursor_for_row(visible, selected_row)
+        closed.with(cursor: selected_cursor || reclamp_cursor(visible, closed.cursor))
+      end
+
+      def cursor_for_row(snapshot, selected_row)
+        return nil if selected_row.nil?
+
+        snapshot.projects.each_with_index do |project, project_idx|
+          folder_idx = project.rows.index { |row| row.folder == selected_row.folder }
+          return [ project_idx, folder_idx ] if folder_idx
+        end
+        snapshot.projects.each_with_index do |project, project_idx|
+          identity_idx = project.rows.index do |row|
+            row.project_name == selected_row.project_name &&
+              row.slug == selected_row.slug &&
+              row.stage == selected_row.stage
+          end
+          return [ project_idx, identity_idx ] if identity_idx
+        end
+        nil
+      end
+
       # `n == 0` clears scope (all projects). Out-of-range still flips
       # the scope (Snapshot returns an empty-projects view); cursor
       # resets to the first non-empty project or nil if the scoped grid
diff --git a/lib/hive/tui/views/idea_preview.rb b/lib/hive/tui/views/idea_preview.rb
deleted file mode 100644
index c9fdfb09..00000000
--- a/lib/hive/tui/views/idea_preview.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-require "hive/tui/styles"
-require "hive/tui/views/format"
-
-module Hive
-  module Tui
-    module Views
-      # Bottom-strip preview for a task's source idea.md original_text.
-      # Read-only: KeyMap routes every key in :idea_preview mode back
-      # to grid; this view only renders the captured model fields.
-      module IdeaPreview
-        DISMISS_HINT = "press any key to dismiss".freeze
-        MAX_VISIBLE_ROWS = 6
-
-        module_function
-
-        def render(model, width: model.cols.to_i)
-          usable = [ width.to_i, 1 ].max
-          rows = [
-            Styles::HINT.render(truncate("Idea for #{model.idea_preview_slug}:", usable)),
-            *body_rows(model.idea_preview_text.to_s, usable),
-            Styles::HINT.render(truncate(DISMISS_HINT, usable))
-          ]
-          rows.join("\n")
-        end
-
-        def body_rows(text, width)
-          return [] if text.empty?
-
-          wrap_text(text, width).first(MAX_VISIBLE_ROWS).map { |line| truncate(line, width) }
-        end
-
-        # Intentional local copy of NewIdeaPrompt's simple chunking shape.
-        # NewIdeaPrompt's helper is cursor-aware and attachment-aware;
-        # extracting it would widen this read-only view change.
-        def chunk_buffer(buffer, capacity)
-          return [ "" ] if buffer.empty?
-
-          chunks = []
-          offset = 0
-          while offset < buffer.length
-            chunks << buffer[offset, capacity].to_s
-            offset += capacity
-          end
-          chunks
-        end
-
-        def wrap_text(text, width)
-          capacity = [ width.to_i, 1 ].max
-          text.each_line(chomp: true).flat_map do |line|
-            chunk_buffer(line, capacity)
-          end
-        end
-
-        def truncate(line, width)
-          Views::Format.truncate(line, width.to_i)
-        end
-      end
-    end
-  end
-end
diff --git a/lib/hive/tui/views/task_info.rb b/lib/hive/tui/views/task_info.rb
new file mode 100644
index 00000000..5ebe9876
--- /dev/null
+++ b/lib/hive/tui/views/task_info.rb
@@ -0,0 +1,185 @@
+require "hive/tui/styles"
+require "hive/tui/text"
+
+module Hive
+  module Tui
+    module Views
+      # Fixed-height, read-only task information panel. It has no scroll
+      # state: content beyond the terminal row budget is replaced by a
+      # visible ellipsis while the close hint remains anchored at bottom.
+      module TaskInfo
+        CLOSE_HINT = "[q/Esc/i] close".freeze
+        Line = Data.define(:text, :style)
+
+        module_function
+
+        def render(model)
+          state = model.task_info_state
+          return "" if state.nil?
+
+          width = [ model.cols.to_i - 1, 1 ].max
+          height = [ model.rows.to_i, 1 ].max
+          capacity = [ height - 1, 0 ].max
+          content = content_lines(state, width, limit: capacity + 1)
+          visible = fit_content(content, width, capacity).map { |line| render_line(line) }
+          visible << Styles::HINT.render(truncate(CLOSE_HINT, width))
+          visible.join("\n")
+        end
+
+        def content_lines(state, width, limit:)
+          title = state.info&.slug
+          title = state.row.slug if title.nil? && state.row&.respond_to?(:slug)
+          lines = []
+          append_line(lines, truncate("Task info · #{safe(title)}", width),
+                      style: Styles::HEADER, limit: limit)
+          append_line(lines, "", limit: limit)
+
+          case state.status
+          when :loading
+            append_line(lines, "Loading task info…", limit: limit)
+          when :failed
+            append_line(lines, "Could not load task info", limit: limit)
+            append_wrapped(lines, state.error.to_s, width, limit: limit)
+          when :loaded
+            append_loaded_lines(lines, state.info, width, limit: limit)
+          else
+            append_line(lines, "Task info unavailable", limit: limit)
+          end
+          lines
+        end
+
+        def append_loaded_lines(lines, info, width, limit:)
+          unless info
+            append_line(lines, "Task info unavailable", limit: limit)
+            return
+          end
+
+          append_wrapped(lines, "Slug: #{info.slug}", width, limit: limit)
+          append_wrapped(lines, "Stage: #{info.stage}", width, limit: limit)
+          append_wrapped(lines, "Created: #{info.created_at}", width, limit: limit)
+          append_wrapped(lines, "Task folder: #{info.task_folder}", width, limit: limit)
+          append_wrapped(lines, "Latest log: #{info.latest_log_path}", width, limit: limit)
+          append_line(lines, "", limit: limit)
+          append_line(lines, truncate("Original idea", width),
+                      style: Styles::HEADER, limit: limit)
+          append_wrapped(lines, info.original_text, width, limit: limit)
+          if info.extra_label
+            append_line(lines, "", limit: limit)
+            append_line(lines, truncate(safe(info.extra_label), width),
+                        style: Styles::HEADER, limit: limit)
+            append_wrapped(lines, info.extra_content.to_s, width, limit: limit)
+          end
+        end
+
+        def fit_content(lines, width, capacity)
+          return [] if capacity <= 0
+          return lines if lines.length <= capacity
+
+          visible = lines.first(capacity)
+          visible[-1] = visible.last.with(text: ellipsis_line(visible.last.text, width))
+          visible
+        end
+
+        def ellipsis_line(line, width)
+          clean = truncate(line, width)
+          return "…" if width <= 1
+          return "#{clean}…" if display_width(clean) < width
+
+          "#{take_cells(clean, width - 1)}…"
+        end
+
+        def wrap(text, width, limit:)
+          return [] if limit <= 0
+
+          wrapped = []
+          saw_line = false
+          text.to_s.each_line(chomp: true) do |line|
+            saw_line = true
+            clean = safe(line)
+            if clean.empty?
+              wrapped << ""
+            else
+              wrapped.concat(wrap_line(clean, width, limit - wrapped.length))
+            end
+            break if wrapped.length >= limit
+          end
+          wrapped << "" unless saw_line
+          wrapped.first(limit)
+        end
+
+        def wrap_line(line, width, limit)
+          chunks = []
+          current = +""
+          current_width = 0
+          line.each_grapheme_cluster do |cluster|
+            cluster_width = display_width(cluster)
+            if cluster_width > width
+              chunks << current unless current.empty?
+              chunks << "…" if chunks.length < limit
+              current = +""
+              current_width = 0
+            elsif current_width + cluster_width > width
+              chunks << current
+              current = +cluster
+              current_width = cluster_width
+            else
+              current << cluster
+              current_width += cluster_width
+            end
+            break if chunks.length >= limit
+          end
+          chunks << current if chunks.length < limit && !current.empty?
+          chunks
+        end
+
+        def append_line(lines, text, style: nil, limit:)
+          return if lines.length >= limit
+
+          lines << Line.new(text: text, style: style)
+        end
+
+        def append_wrapped(lines, text, width, limit:)
+          remaining = limit - lines.length
+          return if remaining <= 0
+
+          wrap(text, width, limit: remaining).each do |line|
+            lines << Line.new(text: line, style: nil)
+          end
+        end
+
+        def render_line(line)
+          line.style ? line.style.render(line.text) : line.text
+        end
+
+        def safe(text)
+          Hive::Tui::Text.sanitize(text)
+        end
+
+        def truncate(text, width)
+          return "" if width <= 0
+          return text if display_width(text) <= width
+          return "…" if width == 1
+
+          "#{take_cells(text, width - 1)}…"
+        end
+
+        def take_cells(text, width)
+          result = +""
+          used = 0
+          text.each_grapheme_cluster do |cluster|
+            cluster_width = display_width(cluster)
+            break if used + cluster_width > width
+
+            result << cluster
+            used += cluster_width
+          end
+          result
+        end
+
+        def display_width(text)
+          Lipgloss.width(text)
+        end
+      end
+    end
+  end
+end
diff --git a/test/e2e/lib/repro_script_writer.rb b/test/e2e/lib/repro_script_writer.rb
index 2264457a..c3119a6d 100644
--- a/test/e2e/lib/repro_script_writer.rb
+++ b/test/e2e/lib/repro_script_writer.rb
@@ -9,7 +9,7 @@ require_relative "string_expander"
 module Hive
   module E2E
     class ReproScriptWriter
-      LIVE_TMUX_KINDS = %w[tui_keys tui_expect wait_subprocess].freeze
+      LIVE_TMUX_KINDS = %w[tui_keys tui_expect wait_subprocess wait_tui_exit].freeze
 
       def initialize(scenario_dir:, sandbox_dir:, run_home:, steps:, failed_index:, scenario_name: nil, expander_context: nil)
         @scenario_dir = scenario_dir
diff --git a/test/e2e/lib/scenario_parser.rb b/test/e2e/lib/scenario_parser.rb
index 25f107c1..c05aeff8 100644
--- a/test/e2e/lib/scenario_parser.rb
+++ b/test/e2e/lib/scenario_parser.rb
@@ -18,7 +18,7 @@ module Hive
 
       STEP_KINDS = %w[
         cli tui_keys tui_expect state_assert json_assert seed_state write_file
-        register_project wait_subprocess editor_action log_assert ruby_block
+        register_project wait_subprocess wait_tui_exit editor_action log_assert ruby_block
       ].freeze
 
       REQUIRED_KEYS = {
diff --git a/test/e2e/lib/scenario_parser_test.rb b/test/e2e/lib/scenario_parser_test.rb
index 6a4b5d27..09644182 100644
--- a/test/e2e/lib/scenario_parser_test.rb
+++ b/test/e2e/lib/scenario_parser_test.rb
@@ -61,6 +61,22 @@ class E2EScenarioParserTest < Minitest::Test
     end
   end
 
+  def test_wait_tui_exit_step_parses_with_timeout
+    Dir.mktmpdir("scenario") do |dir|
+      path = File.join(dir, "wait.yml")
+      File.write(path, <<~YAML)
+        name: wait_ok
+        steps:
+          - kind: wait_tui_exit
+            timeout: 5
+      YAML
+
+      scenario = Hive::E2E::ScenarioParser.parse(path)
+      assert_equal "wait_tui_exit", scenario.steps.first.kind
+      assert_equal 5, scenario.steps.first.args["timeout"]
+    end
+  end
+
   def test_rejects_unsafe_scenario_names_before_runner_uses_paths
     [ "../bad", "/tmp/bad", "nested/name", ".", "bad name" ].each do |name|
       Dir.mktmpdir("scenario") do |dir|
diff --git a/test/e2e/lib/step_executor.rb b/test/e2e/lib/step_executor.rb
index ce02c76d..37afb961 100644
--- a/test/e2e/lib/step_executor.rb
+++ b/test/e2e/lib/step_executor.rb
@@ -245,6 +245,11 @@ module Hive
         tmux.wait_for_subprocess_exit(timeout: (step.args["timeout"] || 30.0).to_f)
       end
 
+      def step_wait_tui_exit(step)
+        tmux = @tmux_lifecycle.start_session
+        tmux.wait_for_tui_exit(timeout: (step.args["timeout"] || 30.0).to_f)
+      end
+
       def step_editor_action(step)
         run_cli_step(step, env_overrides: { "EDITOR" => Paths.editor_shim })
       end
diff --git a/test/e2e/lib/step_executor_test.rb b/test/e2e/lib/step_executor_test.rb
index d52e1003..516030d1 100644
--- a/test/e2e/lib/step_executor_test.rb
+++ b/test/e2e/lib/step_executor_test.rb
@@ -224,4 +224,24 @@ class E2EStepExecutorTest < Minitest::Test
       assert_equal 1, report_for(runs_dir)["summary"]["passed"]
     end
   end
+
+  def test_wait_tui_exit_delegates_to_foreground_tui_lifecycle
+    observed_timeout = nil
+    tmux = Object.new
+    tmux.define_singleton_method(:wait_for_tui_exit) { |timeout:| observed_timeout = timeout }
+    lifecycle = Object.new
+    lifecycle.define_singleton_method(:start_session) { tmux }
+    executor = Hive::E2E::StepExecutor.allocate
+    executor.instance_variable_set(:@tmux_lifecycle, lifecycle)
+    step = Hive::E2E::Step.new(
+      kind: "wait_tui_exit",
+      args: { "timeout" => 5 },
+      description: "",
+      position: 1
+    )
+
+    executor.send(:step_wait_tui_exit, step)
+
+    assert_equal 5.0, observed_timeout
+  end
 end
diff --git a/test/e2e/lib/tmux_driver.rb b/test/e2e/lib/tmux_driver.rb
index 84142243..3843296a 100644
--- a/test/e2e/lib/tmux_driver.rb
+++ b/test/e2e/lib/tmux_driver.rb
@@ -19,6 +19,15 @@ module Hive
 
       class DeadSession < StandardError; end
       class PaneCollapsedError < StandardError; end
+      class TuiFailed < StandardError
+        attr_reader :exit_code
+
+        def initialize(exit_code:)
+          @exit_code = exit_code
+          detail = exit_code.nil? ? "without a recorded exit status" : "with exit #{exit_code}"
+          super("tui exited #{detail}")
+        end
+      end
       class TmuxCommandTimeout < StandardError
         attr_reader :stdout, :stderr, :elapsed
 
@@ -96,6 +105,13 @@ module Hive
         raise "tmux new-session failed: #{err.empty? ? out : err}" unless status.success?
 
         @tmux_session_id = out.strip
+        option_out, option_err, option_status = capture_command(*(base_args + [
+          "set-window-option", "-t", @session_name, "remain-on-exit", "on"
+        ]))
+        unless option_status.success?
+          detail = option_err.empty? ? option_out : option_err
+          raise "tmux remain-on-exit setup failed: #{detail}"
+        end
         @started = true
       end
 
@@ -222,6 +238,15 @@ module Hive
         wait_for_pane_dead(timeout: timeout, interval: interval)
       end
 
+      # Wait for the foreground TUI itself to terminate. This is distinct
+      # from wait_for_subprocess_exit: TUI workflow children are detached
+      # and observed through BEGIN/END markers while `q` ends the pane's
+      # foreground process without writing a subprocess marker.
+      def wait_for_tui_exit(timeout: 30.0, interval: 0.1)
+        start
+        wait_for_pane_dead(timeout: timeout, interval: interval)
+      end
+
       def wait_for_subprocess_log(timeout:, interval:)
         started_at = monotonic_time
         begin_id = nil
@@ -265,10 +290,17 @@ module Hive
         started_at = monotonic_time
         loop do
           out, err, status = capture_command(*(base_args + [
-            "list-panes", "-t", @session_name, "-F", "\#{pane_dead}"
+            "list-panes", "-t", @session_name, "-F", "\#{pane_dead} \#{pane_dead_status}"
           ]))
-          raise "tmux list-panes failed: #{err.empty? ? out : err}" unless status.success?
-          return :ok if out.lines.first.to_s.strip == "1"
+          unless status.success?
+            unless session_alive?
+              raise DeadSession, "tmux session #{@session_name} disappeared before its exit status was observed"
+            end
+
+            raise "tmux list-panes failed: #{err.empty? ? out : err}"
+          end
+          pane_dead, exit_status = out.lines.first.to_s.strip.split(/\s+/, 2)
+          return validate_tui_exit!(exit_status) if pane_dead == "1"
 
           elapsed = monotonic_time - started_at
           raise AnchorTimeout.new(anchor: "pane_dead", captured: out, elapsed: elapsed) if elapsed >= timeout
@@ -277,6 +309,13 @@ module Hive
         end
       end
 
+      def validate_tui_exit!(status)
+        exit_code = Integer(status, exception: false)
+        raise TuiFailed.new(exit_code: exit_code) unless exit_code&.zero?
+
+        :ok
+      end
+
       def cleanup
         capture_command(*(base_args + [ "kill-server" ]))
         @started = false
@@ -316,12 +355,16 @@ module Hive
       end
 
       def ensure_live!
-        _out, _err, status = capture_command(*(base_args + [ "has-session", "-t", @session_name ]))
-        return if status.success?
+        return if session_alive?
 
         raise DeadSession, "tmux session #{@session_name} is not running on #{@socket_name}"
       end
 
+      def session_alive?
+        _out, _err, status = capture_command(*(base_args + [ "has-session", "-t", @session_name ]))
+        status.success?
+      end
+
       def capture_command(*cmd, timeout: TMUX_COMMAND_TIMEOUT)
         started = monotonic_time
         Open3.popen3(*cmd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
diff --git a/test/e2e/lib/tmux_driver_test.rb b/test/e2e/lib/tmux_driver_test.rb
index 6abd7761..0543802b 100644
--- a/test/e2e/lib/tmux_driver_test.rb
+++ b/test/e2e/lib/tmux_driver_test.rb
@@ -124,6 +124,24 @@ class E2ETmuxDriverTest < Minitest::Test
     end
   end
 
+  def test_start_keeps_dead_pane_to_observe_foreground_exit_status
+    driver = make_driver(session_name: "remain-on-exit")
+    successful_status = Struct.new(:success?).new(true)
+    commands = []
+    driver.define_singleton_method(:capture_command) do |*cmd, **_kwargs|
+      commands << cmd
+      output = cmd.include?("new-session") ? "$1\n" : ""
+      [ output, "", successful_status ]
+    end
+
+    driver.start
+
+    option_command = commands.find { |command| command.include?("set-window-option") }
+    assert option_command, "start must configure the pane before waiting on its exit"
+    assert_includes option_command, "remain-on-exit"
+    assert_includes option_command, "on"
+  end
+
   def test_wait_for_subprocess_exit_observes_log_marker_without_pane_death
     Dir.mktmpdir("tmux-subprocess") do |dir|
       log_path = File.join(dir, "hive-tui-subprocess.log")
@@ -152,6 +170,45 @@ class E2ETmuxDriverTest < Minitest::Test
     end
   end
 
+  def test_wait_for_tui_exit_rejects_session_disappearing_without_an_exit_status
+    driver = make_driver(session_name: "tui-exit")
+    driver.instance_variable_set(:@started, true)
+    failed_status = Struct.new(:success?).new(false)
+    driver.define_singleton_method(:capture_command) do |*_cmd, **_kwargs|
+      [ "", "can't find session", failed_status ]
+    end
+
+    assert_raises(Hive::E2E::TmuxDriver::DeadSession) do
+      driver.wait_for_tui_exit(timeout: 0.1, interval: 0.01)
+    end
+  end
+
+  def test_wait_for_tui_exit_rejects_nonzero_pane_status
+    driver = make_driver(session_name: "tui-crash")
+    driver.instance_variable_set(:@started, true)
+    successful_status = Struct.new(:success?).new(true)
+    driver.define_singleton_method(:capture_command) do |*_cmd, **_kwargs|
+      [ "1 7\n", "", successful_status ]
+    end
+
+    error = assert_raises(Hive::E2E::TmuxDriver::TuiFailed) do
+      driver.wait_for_tui_exit(timeout: 0.1, interval: 0.01)
+    end
+
+    assert_equal 7, error.exit_code
+  end
+
+  def test_wait_for_tui_exit_accepts_zero_pane_status
+    driver = make_driver(session_name: "tui-clean-exit")
+    driver.instance_variable_set(:@started, true)
+    successful_status = Struct.new(:success?).new(true)
+    driver.define_singleton_method(:capture_command) do |*_cmd, **_kwargs|
+      [ "1 0\n", "", successful_status ]
+    end
+
+    assert_equal :ok, driver.wait_for_tui_exit(timeout: 0.1, interval: 0.01)
+  end
+
   def test_wait_for_subprocess_exit_ignores_mismatched_end_marker
     Dir.mktmpdir("tmux-subprocess") do |dir|
       log_path = File.join(dir, "hive-tui-subprocess.log")
diff --git a/test/e2e/scenarios/_template.yml b/test/e2e/scenarios/_template.yml
index 5ac7471c..a14d6e6b 100644
--- a/test/e2e/scenarios/_template.yml
+++ b/test/e2e/scenarios/_template.yml
@@ -102,13 +102,18 @@ steps:
   - kind: tui_keys
     keys: "q"
 
-  # wait_subprocess — wait for the foreground process inside the tmux pane to
-  # exit. Used after `tui_keys: "q"` to confirm the TUI process has fully
-  # torn down before asserting downstream state.
+  # wait_subprocess — wait for a TUI-dispatched workflow child to emit its
+  # run-scoped END marker.
   # Optional: timeout (seconds, default 30.0)
   - kind: wait_subprocess
     timeout: 1
 
+  # wait_tui_exit — wait for the foreground TUI process to terminate. Use
+  # after `tui_keys: "q"` to prove the TUI and tracked workers fully tore down.
+  # Optional: timeout (seconds, default 30.0)
+  - kind: wait_tui_exit
+    timeout: 1
+
   # editor_action — like `cli`, but with EDITOR set to the test editor shim
   # so commands that shell out to $EDITOR (`hive edit`, etc.) are deterministic.
   # Required: args (array)
diff --git a/test/e2e/scenarios/tui_info_panel.yml b/test/e2e/scenarios/tui_info_panel.yml
new file mode 100644
index 00000000..12604fb1
--- /dev/null
+++ b/test/e2e/scenarios/tui_info_panel.yml
@@ -0,0 +1,129 @@
+name: tui_info_panel
+description: |
+  Grid `i` opens the full-screen read-only task info panel, ignores
+  unmapped input, restores the selected task, and keeps task files unchanged.
+tags: [tui, info]
+steps:
+  - kind: seed_state
+    stage: 2-brainstorm
+    slug: tui-info-task
+    state_file: brainstorm.md
+    content: |
+      # Brainstorm
+
+      Brainstorm artifact stays unchanged
+
+      <!-- COMPLETE -->
+    files:
+      - path: idea.md
+        content: |
+          ---
+          created_at: 2026-05-22T12:00:00Z
+          original_text: Full original idea stays unchanged
+          ---
+
+          # Full original idea stays unchanged
+
+  - kind: write_file
+    path: "{sandbox}/.hive-state/logs/tui-info-task/brainstorm-001.log"
+    content: "info panel log stays unchanged\n"
+
+  # A second row makes selection restoration observable: the cursor starts
+  # on this alphabetically earlier task, then `j` selects tui-info-task.
+  - kind: seed_state
+    stage: 2-brainstorm
+    slug: a-info-other-task
+    state_file: brainstorm.md
+    content: |
+      # Other brainstorm
+
+      <!-- COMPLETE -->
+
+  - kind: tui_expect
+    anchor: "[i] info"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "a-info-other-task"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "tui-info-task"
+    timeout: 5
+
+  - kind: tui_keys
+    keys: "j"
+  - kind: tui_keys
+    keys: "i"
+  - kind: tui_expect
+    anchor: "Task info"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "Slug: tui-info-task"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "Stage: 2-brainstorm"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "Full original idea stays unchanged"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "Task folder: {task_dir:2-brainstorm}"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "Latest log: {sandbox}/.hive-state/logs/tui-info-task/brainstorm-001.log"
+    timeout: 5
+  - kind: tui_expect
+    anchor: "Brainstorm artifact stays unchanged"
+    timeout: 5
+
+  # An unmapped printable key must leave the modal open.
+  - kind: tui_keys
+    keys: "x"
+  - kind: tui_expect
+    anchor: "Task info"
+    timeout: 3
+
+  # `i` closes and restores the selected grid row.
+  - kind: tui_keys
+    keys: "i"
+  - kind: tui_expect
+    anchor: "[i] info"
+    timeout: 5
+  # Reopening must target the same selected row, not the first row.
+  - kind: tui_keys
+    keys: "i"
+  - kind: tui_expect
+    anchor: "Slug: tui-info-task"
+    timeout: 5
+  - kind: tui_keys
+    keys: "i"
+  - kind: tui_expect
+    anchor: "[i] info"
+    timeout: 5
+
+  # `q` is mode-local: first q closes info, second q exits the grid.
+  - kind: tui_keys
+    keys: "i"
+  - kind: tui_expect
+    anchor: "Task info"
+    timeout: 5
+  - kind: tui_keys
+    keys: "q"
+  - kind: tui_expect
+    anchor: "[i] info"
+    timeout: 5
+
+  - kind: state_assert
+    path: "{task_dir:2-brainstorm}/brainstorm.md"
+    marker: { current: COMPLETE }
+    match: "\\A# Brainstorm\\n\\nBrainstorm artifact stays unchanged\\n\\n<!-- COMPLETE -->\\n\\z"
+  - kind: state_assert
+    path: "{task_dir:2-brainstorm}/idea.md"
+    match: "\\A---\\ncreated_at: 2026-05-22T12:00:00Z\\noriginal_text: Full original idea stays unchanged\\n---\\n\\n# Full original idea stays unchanged\\n\\z"
+  - kind: state_assert
+    path: "{sandbox}/.hive-state/logs/tui-info-task/brainstorm-001.log"
+    match: "\\Ainfo panel log stays unchanged\\n\\z"
+
+  - kind: tui_keys
+    keys: "q"
+  - kind: wait_tui_exit
+    timeout: 5
diff --git a/test/e2e/scenarios/tui_new_idea_editing.yml b/test/e2e/scenarios/tui_new_idea_editing.yml
index f0291ad1..0bff12c2 100644
--- a/test/e2e/scenarios/tui_new_idea_editing.yml
+++ b/test/e2e/scenarios/tui_new_idea_editing.yml
@@ -51,3 +51,5 @@ steps:
 
   - kind: tui_keys
     keys: "q"
+  - kind: wait_tui_exit
+    timeout: 5
diff --git a/test/e2e/scenarios/tui_status_navigate_dispatch_plan.yml b/test/e2e/scenarios/tui_status_navigate_dispatch_plan.yml
index 43d969a7..baa031f7 100644
--- a/test/e2e/scenarios/tui_status_navigate_dispatch_plan.yml
+++ b/test/e2e/scenarios/tui_status_navigate_dispatch_plan.yml
@@ -40,3 +40,5 @@ steps:
     timeout: 10
   - kind: tui_keys
     keys: "q"
+  - kind: wait_tui_exit
+    timeout: 5
diff --git a/test/e2e/scenarios/tui_two_pane_navigate.yml b/test/e2e/scenarios/tui_two_pane_navigate.yml
index bbd4fa49..d401211e 100644
--- a/test/e2e/scenarios/tui_two_pane_navigate.yml
+++ b/test/e2e/scenarios/tui_two_pane_navigate.yml
@@ -103,3 +103,5 @@ steps:
   # Clean exit.
   - kind: tui_keys
     keys: "q"
+  - kind: wait_tui_exit
+    timeout: 5
diff --git a/test/e2e/scenarios/two_projects_fuzzy_filter.yml b/test/e2e/scenarios/two_projects_fuzzy_filter.yml
index 23315c6c..36311f21 100644
--- a/test/e2e/scenarios/two_projects_fuzzy_filter.yml
+++ b/test/e2e/scenarios/two_projects_fuzzy_filter.yml
@@ -37,3 +37,5 @@ steps:
     timeout: 5
   - kind: tui_keys
     keys: "q"
+  - kind: wait_tui_exit
+    timeout: 5
diff --git a/test/unit/tui/bubble_model_test.rb b/test/unit/tui/bubble_model_test.rb
index bfc3e63b..7152b94b 100644
--- a/test/unit/tui/bubble_model_test.rb
+++ b/test/unit/tui/bubble_model_test.rb
@@ -46,28 +46,6 @@ class HiveTuiBubbleModelTest < Minitest::Test
     old_editor.nil? ? ENV.delete("EDITOR") : ENV["EDITOR"] = old_editor
   end
 
-  def write_idea_md(dir, original_text:)
-    indented_original = original_text.lines.map { |line| "  #{line.chomp}" }
-    body = [
-      "---",
-      "slug: some-slug",
-      "created_at: 2026-05-20T00:00:00Z",
-      "original_text: |",
-      *indented_original,
-      "---",
-      "",
-      "# some-slug",
-      "",
-      original_text,
-      "",
-      "<!-- WAITING -->",
-      ""
-    ].join("\n")
-    path = File.join(dir, "idea.md")
-    File.write(path, body)
-    path
-  end
-
   def write_review_doc(path, accepted: false)
     mark = accepted ? "x" : " "
     File.write(path, "## High\n- [#{mark}] First finding: useful rationale\n")
@@ -345,18 +323,27 @@ 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_task_info_without_underlying_grid
+    info = Hive::Tui::TaskInfo::Snapshot.new(
+      slug: "some-slug", stage: "2-brainstorm", created_at: "2026-05-22T00:00:00Z",
+      original_text: "original idea", task_folder: "/tmp/task", latest_log_path: "/tmp/task.log",
+      extra_label: "Brainstorm", extra_content: "artifact", warnings: [], source_paths: []
+    )
+    state = Hive::Tui::Model::TaskInfoState.new(
+      row: make_task_row, request_id: "request-1", status: :loaded, info: info
+    )
     @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: :task_info,
+        task_info_state: state
       ),
       dispatch: @dispatch
     )
     out = @model.view
-    assert_includes out, "Idea for some-slug:"
+    assert_includes out, "Task info"
     assert_includes out, "original idea"
+    refute_includes out, "ProjectsPane"
+    refute_includes out, "[Tab] switch"
   end
 
   # Regression: paste-truncated / paste-timeout / overflow flashes
@@ -603,28 +590,34 @@ 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_includes_info_between_help_and_quit
     hint = @model.send(:footer_hint)
-    assert_equal "[Tab] switch  [Enter] action  [n] new  [/] filter  [?] help  [q] quit",
-                 hint,
-                 "footer hint must remain the pre-`o` literal; `o` is documented in `?` only"
-    refute_includes hint, "[o] open",
-                    "70-col budget can't absorb `[o] open` alongside primary hints"
-    # Width guard: pin the actual character count so a future contributor
-    # who adds a hint and (correctly) bumps the literal above also has to
-    # acknowledge they're spending bytes against the 70-col budget. If
-    # this assertion fires alongside an updated literal, the contributor
-    # MUST verify default_footer truncation behavior at cols == 70.
-    assert hint.length <= 70,
-           "footer hint must fit the 70-col budget without truncation; got #{hint.length} chars"
+    expected = "[Tab] switch  [Enter] action  [n] new  [/] filter  [?] help  [i] info  [q] quit"
+
+    assert_equal expected, hint
+    assert_operator hint.index("[?] help"), :<, hint.index("[i] info")
+    assert_operator hint.index("[i] info"), :<, hint.index("[q] quit")
+    assert_equal 79, hint.length
+  end
+
+  def test_default_80_column_grid_renders_full_footer_on_one_line
+    @model = Hive::Tui::BubbleModel.new(
+      hive_model: Hive::Tui::Model.initial.with(cols: 80),
+      dispatch: @dispatch
+    )
+
+    footer = @model.view.lines(chomp: true).find { |line| line.include?("[Tab] switch") }
+
+    assert_equal @model.send(:footer_hint), footer
+    assert_equal 79, footer.length
+  end
+
+  def test_narrow_footer_is_truncated_without_wrapping
+    footer = @model.send(:default_footer, 69)
+
+    assert_equal 69, footer.length
+    assert footer.end_with?("…")
+    refute_includes footer, "\n"
   end
 
   def test_grid_mode_collapses_to_single_pane_below_min_cols
@@ -3658,118 +3651,105 @@ class HiveTuiBubbleModelTest < Minitest::Test
       "OpenTaskFolder must not dispatch any follow-up message — no auto-continue, no InputEditorExited"
   end
 
-  # ---- OpenIdeaPreview → bottom-strip preview (read-only) ----
-
-  def test_open_idea_preview_reads_original_text_and_enters_preview_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))
+  # ---- OpenTaskInfo → tracked asynchronous read-only load ----
 
-      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
+  def test_open_task_info_enters_loading_before_worker_finishes
+    gate = Queue.new
+    loader = lambda do |_folder|
+      gate.pop
+      :loaded_info
     end
-  end
-
-  def test_open_idea_preview_flashes_when_folder_empty
-    row = make_task_row(folder: "")
+    @model = Hive::Tui::BubbleModel.new(
+      hive_model: Hive::Tui::Model.initial,
+      dispatch: @dispatch,
+      task_info_loader: loader
+    )
+    row = make_task_row
 
-    _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row))
+    _, cmd = @model.update(Hive::Tui::Messages::OpenTaskInfo.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
-    with_tmp_dir do |dir|
-      row = make_task_row(folder: dir)
-
-      _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row))
-
-      assert_nil cmd
-      assert_equal :grid, @model.hive_model.mode
-      assert_match(/no idea\.md for some-slug/, @model.hive_model.flash.to_s)
-    end
+    assert_equal :task_info, @model.hive_model.mode
+    assert_equal :loading, @model.hive_model.task_info_state.status
+    gate << true
+    @model.wait_for_background_threads
+    message = @messages.find { |item| item.is_a?(Hive::Tui::Messages::TaskInfoLoaded) }
+    refute_nil message
+    assert_equal @model.hive_model.task_info_state.request_id, message.request_id
   end
 
-  def test_open_idea_preview_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)
+  def test_open_task_info_worker_dispatches_sanitized_failure
+    loader = ->(_folder) { raise Errno::ENOENT, "\e[31mgone\e[0m" }
+    @model = Hive::Tui::BubbleModel.new(
+      hive_model: Hive::Tui::Model.initial,
+      dispatch: @dispatch,
+      task_info_loader: loader
+    )
 
-      _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row))
+    @model.update(Hive::Tui::Messages::OpenTaskInfo.new(row: make_task_row))
+    @model.wait_for_background_threads
 
-      assert_nil cmd
-      assert_equal :grid, @model.hive_model.mode
-      assert_match(/idea has no original_text for some-slug/, @model.hive_model.flash.to_s)
-    end
+    message = @messages.find { |item| item.is_a?(Hive::Tui::Messages::TaskInfoFailed) }
+    refute_nil message
+    refute_includes message.error, "\e"
+    assert_match(/ENOENT/, message.error)
   end
 
-  def test_open_idea_preview_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))
-
-      assert_nil cmd
-      assert_equal :grid, @model.hive_model.mode
-      assert_match(/could not read idea for some-slug/, @model.hive_model.flash.to_s)
+  def test_closing_task_info_cancels_the_superseded_loader_before_reopen
+    started = Queue.new
+    first_gate = Queue.new
+    calls = 0
+    loader = lambda do |_folder|
+      call = calls
+      calls += 1
+      started << call
+      first_gate.pop if call.zero?
+      :"loaded_info_#{call}"
     end
-  end
-
-  def test_open_idea_preview_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))
+    @model = Hive::Tui::BubbleModel.new(
+      hive_model: Hive::Tui::Model.initial,
+      dispatch: @dispatch,
+      task_info_loader: loader
+    )
 
-      assert_nil cmd
-      assert_empty @messages
-      assert_equal before, File.read(idea_path)
-    end
-  end
+    @model.update(Hive::Tui::Messages::OpenTaskInfo.new(row: make_task_row(slug: "first")))
+    assert_equal 0, started.pop
+    first_thread = @model.instance_variable_get(:@heal_threads).first
+    assert first_thread.alive?
 
-  def test_open_idea_preview_truncates_oversized_original_text
-    with_tmp_dir do |dir|
-      original = "x" * (Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS + 20)
-      write_idea_md(dir, original_text: original)
-      row = make_task_row(folder: dir)
+    @model.update(Hive::Tui::Messages::BACK)
+    first_thread.join(0.5)
+    refute first_thread.alive?, "closing the panel must cancel its now-superseded loader"
 
-      _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row))
+    @model.update(Hive::Tui::Messages::OpenTaskInfo.new(row: make_task_row(slug: "second")))
+    assert_equal 1, started.pop
+    @model.wait_for_background_threads
 
-      assert_nil cmd
-      assert_equal :idea_preview, @model.hive_model.mode
-      assert_equal Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS,
-                   @model.hive_model.idea_preview_text.length
-    end
+    tracked = @model.instance_variable_get(:@heal_threads)
+    assert_operator tracked.size, :<=, 1,
+      "rapid close/reopen must keep task-info loader workers bounded"
+    assert @messages.any? { |item| item.is_a?(Hive::Tui::Messages::TaskInfoLoaded) && item.info == :loaded_info_1 }
   end
 
-  def test_idea_preview_roundtrip_open_then_any_key_dismisses
-    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))
+  def test_grid_i_opens_modal_unmapped_key_is_noop_and_i_closes
+    row = make_task_row
+    @model = Hive::Tui::BubbleModel.new(
+      hive_model: Hive::Tui::Model.initial.with(snapshot: snapshot_with([ row ]), cursor: [ 0, 0 ]),
+      dispatch: @dispatch,
+      task_info_loader: ->(_folder) { :loaded_info }
+    )
 
-      assert_nil open_cmd
-      assert_equal :idea_preview, @model.hive_model.mode
-      assert_equal "Roundtrip idea", @model.hive_model.idea_preview_text
+    @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "i".ord ]))
+    assert_equal :task_info, @model.hive_model.mode
+    selected_cursor = @model.hive_model.cursor
 
-      _, dismiss_cmd = @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "x".ord ]))
+    @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "x".ord ]))
+    assert_equal :task_info, @model.hive_model.mode
 
-      assert_nil dismiss_cmd
-      assert_equal :grid, @model.hive_model.mode
-      assert_nil @model.hive_model.idea_preview_text
-      assert_nil @model.hive_model.idea_preview_slug
-      assert_empty @messages
-    end
+    @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "i".ord ]))
+    assert_equal :grid, @model.hive_model.mode
+    assert_equal selected_cursor, @model.hive_model.cursor
+    @model.wait_for_background_threads
   end
 
   # ---- OpenInAgent → configured agent foreground takeover ----
diff --git a/test/unit/tui/key_map_test.rb b/test/unit/tui/key_map_test.rb
index d58b283e..3311a849 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_task_info
     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::OpenTaskInfo, msg
     assert_equal row, msg.row
   end
 
@@ -258,10 +258,15 @@ 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"
+  def test_task_info_only_q_escape_and_i_return_back
+    [ "i", :key_escape, "\e", "q" ].each do |key|
+      msg = Hive::Tui::KeyMap.message_for(mode: :task_info, key: key, row: nil)
+      assert_same Hive::Tui::Messages::BACK, msg, "#{key.inspect} must close task info"
+    end
+
+    [ "x", :key_enter, :key_up, :key_down, :key_tab, "/", "?", "b", "p", "d", "r", :space ].each do |key|
+      msg = Hive::Tui::KeyMap.message_for(mode: :task_info, key: key, row: nil)
+      assert_same Hive::Tui::Messages::NOOP, msg, "#{key.inspect} must be ignored in task info"
     end
   end
 
@@ -933,7 +938,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 ]
+      [ :task_info, "x",       nil ]
     ]
 
     fixtures.each do |mode, key, row|
diff --git a/test/unit/tui/log_tail_test.rb b/test/unit/tui/log_tail_test.rb
index c0939a8b..f3f8b048 100644
--- a/test/unit/tui/log_tail_test.rb
+++ b/test/unit/tui/log_tail_test.rb
@@ -122,6 +122,21 @@ class TuiLogTailTest < Minitest::Test
     end
   end
 
+  def test_latest_accepts_a_narrow_filename_pattern
+    with_log_dir do |dir|
+      execute = File.join(dir, "execute-001.log")
+      review = File.join(dir, "review-001.log")
+      File.write(execute, "execute\n")
+      File.write(review, "review\n")
+      now = Time.now
+      File.utime(now - 10, now - 10, execute)
+      File.utime(now, now, review)
+
+      assert_equal review, Hive::Tui::LogTail::FileResolver.latest(dir)
+      assert_equal execute, Hive::Tui::LogTail::FileResolver.latest(dir, pattern: "execute-*.log")
+    end
+  end
+
   # The TOCTOU race between Dir[] glob and File.mtime on a rotating
   # log directory used to surface as Errno::ENOENT crashing the TUI.
   # Reproduce the race by overriding Dir.[] to return a path that no
diff --git a/test/unit/tui/messages_test.rb b/test/unit/tui/messages_test.rb
index 322dce55..0c797501 100644
--- a/test/unit/tui/messages_test.rb
+++ b/test/unit/tui/messages_test.rb
@@ -59,12 +59,18 @@ class HiveTuiMessagesTest < Minitest::Test
     assert_same row, msg.row
   end
 
-  def test_open_idea_preview_carries_row
+  def test_task_info_messages_carry_request_identity
     row = Object.new
-    msg = Hive::Tui::Messages::OpenIdeaPreview.new(row: row)
-
-    assert_same row, msg.row
-    assert_includes Hive::Tui::Messages::OpenIdeaPreview.members, :row
+    info = Object.new
+    open = Hive::Tui::Messages::OpenTaskInfo.new(row: row, request_id: "request-1")
+    loaded = Hive::Tui::Messages::TaskInfoLoaded.new(request_id: "request-1", info: info)
+    failed = Hive::Tui::Messages::TaskInfoFailed.new(request_id: "request-1", error: "unreadable")
+
+    assert_same row, open.row
+    assert_equal "request-1", open.request_id
+    assert_same info, loaded.info
+    assert_equal "request-1", loaded.request_id
+    assert_equal "unreadable", failed.error
   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 efcf5716..42dddc99 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.task_info_state
     assert_nil model.flash
     assert_nil model.flash_set_at
     assert_nil model.tail_state
@@ -65,15 +64,13 @@ class HiveTuiModelTest < Minitest::Test
     assert_equal 2, b.scope
   end
 
-  def test_with_updates_idea_preview_fields
-    a = Hive::Tui::Model.initial
-    b = a.with(idea_preview_text: "original idea", idea_preview_slug: "ship-preview")
+  def test_task_info_state_is_immutable_and_defaults_to_loading
+    state = Hive::Tui::Model::TaskInfoState.new(row: Object.new, request_id: "request-1")
 
-    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
-    refute_same a, b
+    assert_equal :loading, state.status
+    assert_nil state.info
+    assert_nil state.error
+    assert state.frozen?
   end
 
   def test_model_is_immutable
@@ -129,7 +126,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 task_info_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/task_info_test.rb b/test/unit/tui/task_info_test.rb
new file mode 100644
index 00000000..b07f589c
--- /dev/null
+++ b/test/unit/tui/task_info_test.rb
@@ -0,0 +1,183 @@
+require "test_helper"
+require "digest"
+require "hive/tui/task_info"
+
+class HiveTuiTaskInfoTest < Minitest::Test
+  include HiveTestHelper
+
+  def create_task(root, stage:, slug: "ship-info")
+    folder = File.join(root, ".hive-state", "stages", stage, slug)
+    FileUtils.mkdir_p(folder)
+    folder
+  end
+
+  def write_idea(folder, created_at: "2026-05-22T12:00:00Z", original_text: "Original idea")
+    File.write(
+      File.join(folder, "idea.md"),
+      "---\ncreated_at: #{created_at}\noriginal_text: |\n  #{original_text}\n---\n\n# Idea\n\nBody fallback\n"
+    )
+  end
+
+  def write_log(root, slug, name, content, mtime:)
+    dir = File.join(root, ".hive-state", "logs", slug)
+    FileUtils.mkdir_p(dir)
+    path = File.join(dir, name)
+    File.write(path, content)
+    File.utime(mtime, mtime, path)
+    path
+  end
+
+  def test_inbox_loads_common_fields_and_no_extra
+    with_tmp_dir do |root|
+      folder = create_task(root, stage: "1-inbox")
+      write_idea(folder, original_text: "Keep the full source idea")
+      latest = write_log(root, "ship-info", "brainstorm-001.log", "line\n", mtime: Time.now)
+
+      info = Hive::Tui::TaskInfo.load(folder)
+
+      assert_equal "ship-info", info.slug
+      assert_equal "1-inbox", info.stage
+      assert_equal "2026-05-22T12:00:00Z", info.created_at
+      assert_equal "Keep the full source idea", info.original_text
+      assert_equal File.expand_path(folder), info.task_folder
+      assert_equal File.expand_path(latest), info.latest_log_path
+      assert_nil info.extra_label
+      assert_nil info.extra_content
+      assert info.frozen?
+    end
+  end
+
+  def test_stage_artifacts_are_loaded_for_brainstorm_and_plan
+    with_tmp_dir do |root|
+      {
+        "2-brainstorm" => [ "Brainstorm", "brainstorm.md", "# Brainstorm\n\nComplete notes\n" ],
+        "3-plan" => [ "Plan", "plan.md", "# Plan\n\nComplete plan\n" ]
+      }.each do |stage, (label, filename, content)|
+        folder = create_task(root, stage: stage, slug: "ship-#{label.downcase}")
+        write_idea(folder)
+        File.write(File.join(folder, filename), content)
+
+        info = Hive::Tui::TaskInfo.load(folder)
+
+        assert_equal label, info.extra_label
+        assert_equal content, info.extra_content
+      end
+    end
+  end
+
+  def test_idea_and_stage_artifact_reads_preserve_full_content
+    with_tmp_dir do |root|
+      idea = "idea-" + ("i" * ((256 * 1024) + 1024))
+      {
+        "2-brainstorm" => [ "brainstorm.md", "brainstorm-" + ("b" * ((256 * 1024) + 1024)) ],
+        "3-plan" => [ "plan.md", "plan-" + ("p" * ((256 * 1024) + 1024)) ]
+      }.each do |stage, (filename, artifact)|
+        folder = create_task(root, stage: stage, slug: "ship-#{stage}")
+        File.binwrite(File.join(folder, "idea.md"), idea)
+        File.binwrite(File.join(folder, filename), artifact)
+
+        info = Hive::Tui::TaskInfo.load(folder)
+
+        assert_equal Digest::SHA256.hexdigest(idea), Digest::SHA256.hexdigest(info.original_text)
+        assert_equal Digest::SHA256.hexdigest(artifact), Digest::SHA256.hexdigest(info.extra_content)
+        refute info.warnings.any? { |warning| warning.include?("truncated") }
+      end
+    end
+  end
+
+  def test_task_moving_stages_during_load_is_rejected
+    with_tmp_dir do |root|
+      folder = create_task(root, stage: "2-brainstorm")
+      write_idea(folder)
+      File.write(File.join(folder, "brainstorm.md"), "Complete notes\n")
+      destination = File.join(root, ".hive-state", "stages", "3-plan", "ship-info")
+      original = Hive::Tui::TaskInfo.method(:load_extra)
+      moving_load = lambda do |task, warnings, source_paths|
+        result = original.call(task, warnings, source_paths)
+        FileUtils.mkdir_p(File.dirname(destination))
+        FileUtils.mv(task.folder, destination)
+        result
+      end
+
+      error = with_replaced_singleton_method(Hive::Tui::TaskInfo, :load_extra, moving_load) do
+        assert_raises(Hive::Tui::TaskInfo::LoadError) do
+          Hive::Tui::TaskInfo.load(folder)
+        end
+      end
+
+      assert_match(/moved or disappeared/, error.message)
+    end
+  end
+
+  def test_execute_uses_latest_overall_log_path_but_latest_execute_tail
+    with_tmp_dir do |root|
+      folder = create_task(root, stage: "4-execute")
+      write_idea(folder)
+      old_execute = write_log(root, "ship-info", "execute-001.log", "old\n", mtime: Time.now - 30)
+      latest_execute = write_log(root, "ship-info", "execute-002.log", "one\ntwo\n", mtime: Time.now - 20)
+      latest_overall = write_log(root, "ship-info", "review-001.log", "newer\n", mtime: Time.now - 10)
+
+      info = Hive::Tui::TaskInfo.load(folder)
+
+      refute_equal old_execute, info.latest_log_path
+      assert_equal File.expand_path(latest_overall), info.latest_log_path
+      assert_equal "Execute log", info.extra_label
+      assert_equal "one\ntwo", info.extra_content
+      assert_includes info.source_paths, File.expand_path(latest_execute)
+    end
+  end
+
+  def test_missing_optional_values_remain_available_explicitly
+    with_tmp_dir do |root|
+      folder = create_task(root, stage: "2-brainstorm")
+      File.write(File.join(folder, "idea.md"), "---\nslug: ship-info\n---\n")
+
+      info = Hive::Tui::TaskInfo.load(folder)
+
+      assert_equal Hive::Tui::TaskInfo::UNAVAILABLE, info.created_at
+      assert_equal Hive::Tui::TaskInfo::UNAVAILABLE, info.original_text
+      assert_equal Hive::Tui::TaskInfo::NONE, info.latest_log_path
+      assert_equal "Brainstorm", info.extra_label
+      assert_equal Hive::Tui::TaskInfo::UNAVAILABLE, info.extra_content
+    end
+  end
+
+  def test_malformed_frontmatter_falls_back_to_markdown_body
+    with_tmp_dir do |root|
+      folder = create_task(root, stage: "1-inbox")
+      File.write(File.join(folder, "idea.md"), "---\ncreated_at: [broken\n---\n\nBody survives malformed YAML\n")
+
+      info = Hive::Tui::TaskInfo.load(folder)
+
+      assert_equal Hive::Tui::TaskInfo::UNAVAILABLE, info.created_at
+      assert_equal "\nBody survives malformed YAML\n", info.original_text
+      assert_match(/malformed idea frontmatter/, info.warnings.join(" "))
+    end
+  end
+
+  def test_invalid_task_path_becomes_a_load_error
+    error = assert_raises(Hive::Tui::TaskInfo::LoadError) do
+      Hive::Tui::TaskInfo.load("/tmp/not-a-hive-task")
+    end
+
+    assert_match(/invalid task path/, error.message)
+  end
+
+  def test_loading_is_read_only
+    with_tmp_dir do |root|
+      folder = create_task(root, stage: "3-plan")
+      write_idea(folder)
+      File.write(File.join(folder, "plan.md"), "Plan stays unchanged\n")
+      before = Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH)
+        .select { |path| File.file?(path) }
+        .to_h { |path| [ path, [ File.binread(path), File.mtime(path) ] ] }
+
+      Hive::Tui::TaskInfo.load(folder)
+
+      after = Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH)
+        .select { |path| File.file?(path) }
+        .to_h { |path| [ path, [ File.binread(path), File.mtime(path) ] ] }
+      assert_equal before, after
+    end
+  end
+end
diff --git a/test/unit/tui/update_test.rb b/test/unit/tui/update_test.rb
index 53fc23ab..bef1dd75 100644
--- a/test/unit/tui/update_test.rb
+++ b/test/unit/tui/update_test.rb
@@ -152,6 +152,95 @@ class HiveTuiUpdateTest < Minitest::Test
                  new_model.red_status_detail_state.agent_label
   end
 
+  def test_task_info_open_load_and_stale_result_protection
+    row = red_detail_row
+    loading, = Hive::Tui::Update.apply(
+      model,
+      Hive::Tui::Messages::OpenTaskInfo.new(row: row, request_id: "request-1")
+    )
+    assert_equal :task_info, loading.mode
+    assert_equal :loading, loading.task_info_state.status
+
+    stale, = Hive::Tui::Update.apply(
+      loading,
+      Hive::Tui::Messages::TaskInfoLoaded.new(request_id: "older", info: Object.new)
+    )
+    assert_same loading, stale
+
+    info = Object.new
+    loaded, = Hive::Tui::Update.apply(
+      loading,
+      Hive::Tui::Messages::TaskInfoLoaded.new(request_id: "request-1", info: info)
+    )
+    assert_equal :loaded, loaded.task_info_state.status
+    assert_same info, loaded.task_info_state.info
+  end
+
+  def test_task_info_failure_and_result_after_close_are_safe
+    row = red_detail_row
+    loading, = Hive::Tui::Update.apply(
+      model,
+      Hive::Tui::Messages::OpenTaskInfo.new(row: row, request_id: "request-1")
+    )
+    failed, = Hive::Tui::Update.apply(
+      loading,
+      Hive::Tui::Messages::TaskInfoFailed.new(request_id: "request-1", error: "gone")
+    )
+    assert_equal :failed, failed.task_info_state.status
+    assert_equal "gone", failed.task_info_state.error
+
+    closed, = Hive::Tui::Update.apply(failed, Hive::Tui::Messages::BACK)
+    late, = Hive::Tui::Update.apply(
+      closed,
+      Hive::Tui::Messages::TaskInfoLoaded.new(request_id: "request-1", info: Object.new)
+    )
+    assert_same closed, late
+    assert_nil late.task_info_state
+  end
+
+  def test_back_from_task_info_restores_selected_row_after_reordering
+    selected = red_detail_row
+    other = selected.with(slug: "other-task", folder: "/tmp/other-task")
+    state = Hive::Tui::Model::TaskInfoState.new(row: selected, request_id: "request-1")
+    starting = model.with(
+      mode: :task_info,
+      task_info_state: state,
+      snapshot: snapshot_with_rows(selected, other),
+      cursor: [ 0, 0 ],
+      scope: 0,
+      pane_focus: :right
+    )
+    refreshed, = Hive::Tui::Update.apply(
+      starting,
+      Hive::Tui::Messages::SnapshotArrived.new(snapshot: snapshot_with_rows(other, selected))
+    )
+
+    closed, = Hive::Tui::Update.apply(refreshed, Hive::Tui::Messages::BACK)
+
+    assert_equal :grid, closed.mode
+    assert_nil closed.task_info_state
+    assert_equal [ 0, 1 ], closed.cursor
+    assert_equal 0, closed.scope
+    assert_equal :right, closed.pane_focus
+  end
+
+  def test_back_from_task_info_reclamps_when_selected_row_disappeared
+    selected = red_detail_row
+    other = selected.with(slug: "other-task", folder: "/tmp/other-task")
+    state = Hive::Tui::Model::TaskInfoState.new(row: selected, request_id: "request-1")
+    starting = model.with(
+      mode: :task_info,
+      task_info_state: state,
+      snapshot: snapshot_with_rows(other),
+      cursor: [ 0, 5 ]
+    )
+
+    closed, = Hive::Tui::Update.apply(starting, Hive::Tui::Messages::BACK)
+
+    assert_equal :grid, closed.mode
+    assert_equal [ 0, 0 ], closed.cursor
+  end
+
   def test_red_status_detail_scroll_down_reduces_offset
     state = Hive::Tui::Model::RedStatusDetailState.new(
       row: red_detail_row,
@@ -1338,33 +1427,6 @@ class HiveTuiUpdateTest < Minitest::Test
     assert_equal :grid, new_model.mode
   end
 
-  def test_back_from_idea_preview_clears_text_and_returns_to_grid
-    starting = model.with(
-      mode: :idea_preview,
-      idea_preview_text: "original idea",
-      idea_preview_slug: "some-slug"
-    )
-    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
-  end
-
-  def test_back_from_idea_preview_preserves_cursor_and_scope
-    starting = model.with(
-      mode: :idea_preview,
-      idea_preview_text: "original idea",
-      idea_preview_slug: "some-slug",
-      cursor: [ 1, 2 ],
-      scope: 2
-    )
-    new_model, _cmd = Hive::Tui::Update.apply(starting, Hive::Tui::Messages::BACK)
-
-    assert_equal [ 1, 2 ], new_model.cursor
-    assert_equal 2, new_model.scope
-  end
-
   def test_project_scope_sets_scope_and_resets_cursor
     starting = model.with(snapshot: snap_with_two_projects_three_rows_each, cursor: [ 0, 2 ])
     new_model, _cmd = Hive::Tui::Update.apply(starting, Hive::Tui::Messages::ProjectScope.new(n: 2))
diff --git a/test/unit/tui/views/idea_preview_test.rb b/test/unit/tui/views/idea_preview_test.rb
deleted file mode 100644
index b18b8def..00000000
--- a/test/unit/tui/views/idea_preview_test.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-require "test_helper"
-require "hive/tui/model"
-require "hive/tui/views/idea_preview"
-
-class HiveTuiViewsIdeaPreviewTest < Minitest::Test
-  include HiveTestHelper
-
-  def model_with(text: "Original idea", slug: "some-slug", cols: 80)
-    Hive::Tui::Model.initial.with(
-      mode: :idea_preview,
-      idea_preview_text: text,
-      idea_preview_slug: slug,
-      cols: cols
-    )
-  end
-
-  def render_lines(**kwargs)
-    Hive::Tui::Views::IdeaPreview.render(model_with(**kwargs), width: kwargs.fetch(:cols, 80)).lines(chomp: true)
-  end
-
-  def test_renders_header_with_slug
-    out = Hive::Tui::Views::IdeaPreview.render(model_with(slug: "preview-me"))
-    assert_includes out, "Idea for preview-me:"
-  end
-
-  def test_renders_original_text_verbatim
-    out = Hive::Tui::Views::IdeaPreview.render(model_with(text: "keep [image1] plain"))
-    assert_includes out, "keep [image1] plain"
-  end
-
-  def test_renders_dismiss_hint
-    out = Hive::Tui::Views::IdeaPreview.render(model_with)
-    assert out.end_with?(Hive::Tui::Views::IdeaPreview::DISMISS_HINT),
-           "dismiss hint must be the final rendered line"
-  end
-
-  def test_truncates_long_lines_to_width
-    lines = render_lines(text: "x" * 80, cols: 20)
-
-    assert lines.all? { |line| line.length <= 20 },
-           "all rendered lines must fit width: #{lines.inspect}"
-  end
-
-  def test_caps_visible_rows_for_oversized_text
-    text = (1..10).map { |i| "line #{i}" }.join("\n")
-    lines = render_lines(text: text)
-    body_lines = lines[1...-1]
-
-    assert_operator body_lines.length, :<=, Hive::Tui::Views::IdeaPreview::MAX_VISIBLE_ROWS
-  end
-
-  def test_handles_nil_text_gracefully
-    lines = render_lines(text: nil, slug: "nil-text")
-
-    assert_equal 2, lines.length
-    assert_includes lines.first, "Idea for nil-text:"
-    assert_equal Hive::Tui::Views::IdeaPreview::DISMISS_HINT, lines.last
-  end
-end
diff --git a/test/unit/tui/views/task_info_test.rb b/test/unit/tui/views/task_info_test.rb
new file mode 100644
index 00000000..642de65c
--- /dev/null
+++ b/test/unit/tui/views/task_info_test.rb
@@ -0,0 +1,137 @@
+require "test_helper"
+require "hive/tui/model"
+require "hive/tui/task_info"
+require "hive/tui/views/task_info"
+
+class HiveTuiViewsTaskInfoTest < Minitest::Test
+  include HiveTestHelper
+
+  def info(extra_label: "Brainstorm", extra_content: "# Notes\nComplete artifact")
+    Hive::Tui::TaskInfo::Snapshot.new(
+      slug: "ship-info",
+      stage: "2-brainstorm",
+      created_at: "2026-05-22T12:00:00Z",
+      original_text: "Full original idea",
+      task_folder: "/tmp/project/.hive-state/stages/2-brainstorm/ship-info",
+      latest_log_path: "/tmp/project/.hive-state/logs/ship-info/brainstorm-001.log",
+      extra_label: extra_label,
+      extra_content: extra_content,
+      warnings: [],
+      source_paths: []
+    )
+  end
+
+  def model_for(status: :loaded, loaded_info: info, error: nil, cols: 100, rows: 24)
+    state = Hive::Tui::Model::TaskInfoState.new(
+      row: Object.new,
+      request_id: "request-1",
+      status: status,
+      info: loaded_info,
+      error: error
+    )
+    Hive::Tui::Model.initial(cols: cols, rows: rows).with(mode: :task_info, task_info_state: state)
+  end
+
+  def test_renders_full_common_and_stage_specific_content
+    out = Hive::Tui::Views::TaskInfo.render(model_for)
+
+    assert_includes out, "Task info"
+    assert_includes out, "Slug: ship-info"
+    assert_includes out, "Stage: 2-brainstorm"
+    assert_includes out, "Created: 2026-05-22T12:00:00Z"
+    assert_includes out, "Task folder: /tmp/project/"
+    assert_includes out, "Latest log: /tmp/project/"
+    assert_includes out, "Original idea"
+    assert_includes out, "Full original idea"
+    assert_includes out, "Brainstorm"
+    assert_includes out, "Complete artifact"
+    assert out.end_with?(Hive::Tui::Views::TaskInfo::CLOSE_HINT)
+  end
+
+  def test_omits_stage_section_when_snapshot_has_none
+    out = Hive::Tui::Views::TaskInfo.render(model_for(loaded_info: info(extra_label: nil, extra_content: nil)))
+
+    refute_includes out, "Brainstorm"
+    assert_includes out, "Original idea"
+  end
+
+  def test_loading_and_failure_states_keep_close_hint
+    loading = Hive::Tui::Views::TaskInfo.render(model_for(status: :loading, loaded_info: nil))
+    failed = Hive::Tui::Views::TaskInfo.render(
+      model_for(status: :failed, loaded_info: nil, error: "ENOENT: gone")
+    )
+
+    assert_includes loading, "Loading task info…"
+    refute_includes loading, "false"
+    assert_includes loading, Hive::Tui::Views::TaskInfo::CLOSE_HINT
+    assert_includes failed, "Could not load task info"
+    assert_includes failed, "ENOENT: gone"
+    assert_includes failed, Hive::Tui::Views::TaskInfo::CLOSE_HINT
+  end
+
+  def test_long_content_is_bounded_and_visibly_truncated
+    long = (1..40).map { |number| "artifact line #{number}" }.join("\n")
+    model = model_for(loaded_info: info(extra_content: long), cols: 40, rows: 10)
+
+    lines = Hive::Tui::Views::TaskInfo.render(model).lines(chomp: true)
+
+    assert_operator lines.length, :<=, 10
+    assert lines.all? { |line| line.length <= 39 }, lines.inspect
+    assert lines.any? { |line| line.include?("…") }, "truncated panel must show an ellipsis"
+    assert_equal Hive::Tui::Views::TaskInfo::CLOSE_HINT, lines.last
+  end
+
+  def test_wide_characters_wrap_by_terminal_display_cells
+    wide = ("界🧠" * 80) + "\ntrailing content"
+    model = model_for(loaded_info: info(extra_content: wide), cols: 20, rows: 10)
+
+    lines = Hive::Tui::Views::TaskInfo.render(model).lines(chomp: true)
+
+    assert_operator lines.length, :<=, 10
+    assert lines.all? { |line| Lipgloss.width(line) <= 19 },
+      "every logical row must fit physically without terminal wrapping: #{lines.inspect}"
+    assert_equal Hive::Tui::Views::TaskInfo::CLOSE_HINT, lines.last
+  end
+
+  def test_file_text_is_sanitized_without_flattening_markdown_lines
+    unsafe = "heading\e[2J\nbody\u0000tail".b
+    unsafe << "\ninvalid-\xff-byte".b
+    out = Hive::Tui::Views::TaskInfo.render(model_for(loaded_info: info(extra_content: unsafe)))
+
+    refute_includes out, "\e"
+    refute_includes out, "\u0000"
+    assert_includes out, "heading"
+    assert_includes out, "body?tail"
+    assert_includes out, "invalid-?-byte"
+  end
+
+  def test_render_only_sanitizes_rows_that_fit_the_terminal_budget
+    long = (1..10_000).map { |number| "artifact line #{number}" }.join("\n")
+    calls = 0
+    sanitizer = lambda do |text|
+      calls += 1
+      Hive::Tui::Text.sanitize(text)
+    end
+
+    with_replaced_singleton_method(Hive::Tui::Views::TaskInfo, :safe, sanitizer) do
+      Hive::Tui::Views::TaskInfo.render(model_for(loaded_info: info(extra_content: long), cols: 40, rows: 10))
+    end
+
+    assert_operator calls, :<, 100,
+      "rendering a short panel must not sanitize and wrap the complete artifact on every frame"
+  end
+
+  def test_overflow_ellipsis_is_applied_before_line_styling
+    style = Object.new
+    style.define_singleton_method(:render) { |text| "\e[1m#{text}\e[0m" }
+    line_class = Hive::Tui::Views::TaskInfo::Line
+    header = line_class.new(text: "abcdef", style: style)
+    overflow = line_class.new(text: "more", style: nil)
+
+    visible = Hive::Tui::Views::TaskInfo.fit_content([ header, overflow ], 4, 1)
+
+    assert_equal "abc…", visible.first.text
+    assert_same style, visible.first.style
+    assert_equal "\e[1mabc…\e[0m", Hive::Tui::Views::TaskInfo.render_line(visible.first)
+  end
+end
diff --git a/wiki/commands/tui.md b/wiki/commands/tui.md
index f46a0504..0dcce4a2 100644
--- a/wiki/commands/tui.md
+++ b/wiki/commands/tui.md
@@ -3,7 +3,7 @@ title: hive tui
 type: command
 source: lib/hive/tui.rb
 created: 2026-04-27
-updated: 2026-05-22
+updated: 2026-07-23
 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: … [/] filter  [?] help  [i] info  [q] quit                     │
 └──────────────────────────────────────────────────────────────────────────┘
 ```
 
@@ -41,6 +41,7 @@ Pane focus is keyboard-only; the focused pane border is bright cyan, the inactiv
 | Two-pane dashboard (default) | boot | `q` |
 | Red-status detail | `Enter` on selected red recovery/error rows | `q` / `Esc` |
 | Agent log tail | `Enter` on an `agent_running` row | `q` / `Esc` |
+| Task info | `i` on the selected task while the right pane is focused | `q` / `Esc` / `i` |
 | 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) |
 | New idea project picker | `n` from `★ All projects` scope | `Esc` / `q` (cancels) / `Enter` (selects and advances to title prompt) |
@@ -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 focused row's full-screen read-only information panel. Shows common idea/task metadata at every stage, plus `brainstorm.md` in `2-brainstorm`, `plan.md` in `3-plan`, or the latest bounded `execute-*.log` tail in `4-execute`. |
 | `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 |
@@ -77,6 +79,20 @@ Pane focus is keyboard-only; the focused pane border is bright cyan, the inactiv
 
 Findings triage is no longer an in-TUI mode. Use `hive findings`, `hive accept-finding`, and `hive reject-finding` directly from a shell or coding agent; legacy `EXECUTE_WAITING findings_count` rows surface as `recover_execute` and point at `hive findings` from status JSON (see [[commands/findings]]). In red-status detail mode, `Enter` runs hive's automated recovery for the task and closes the detail screen (rows with no auto-recovery recipe surface a refusal flash that names `Open in agent` as the manual fallback before closing), `o` opens the task in the project's configured development agent and closes the detail screen, and `q` / `Esc` returns to the grid. The help overlay groups bindings by mode for the disambiguation.
 
+## Task info mode
+
+Pressing `i` on a selected right-pane row changes to a standalone full-screen loading frame immediately. `BubbleModel` then reads the task files on a tracked background worker and dispatches an immutable result back through the MVU loop; request IDs prevent a late result from an earlier open from replacing or reopening the current panel. Closing cancels the active loader, so rapid close/reopen cycles keep at most one task-info worker alive; shutdown still reaps any straggler through the tracked-thread lifecycle used by recovery workers.
+
+Every loaded panel shows the slug, validated current stage, `created_at`, full original idea text (falling back to the Markdown body when `original_text` is absent), absolute task-folder path, and absolute newest `*.log` path. Stage-specific sections are:
+
+- `1-inbox`: none.
+- `2-brainstorm`: the full `brainstorm.md` snapshot.
+- `3-plan`: the full `plan.md` snapshot.
+- `4-execute`: bounded chronological tail of the newest `execute-*.log`, selected independently from the newest overall log.
+- Later stages: common fields only.
+
+Idea, brainstorm, and plan snapshots preserve their full available content; display bounding happens only while rendering the current terminal frame. Missing optional files remain explicit as `unavailable` / `none`; malformed frontmatter can still yield the Markdown body. File bytes are normalized to valid UTF-8 and terminal controls are sanitized before layout. The panel has no scroll state: it reserves the close hint, wraps by terminal display cells (including CJK and emoji), processes only the rows that can fit in the current frame, and replaces overflow with `…`. Only `q`, `Esc`, and `i` close it; every other key is a no-op. The loader validates that the task folder still exists before and after its asynchronous reads, so a task that moves stages cannot publish a stale stage/path snapshot. Closing locates the original task by folder, then by project/slug/stage identity, so benign snapshot reordering does not move the selection. The loader performs no writes, marker changes, workflow dispatch, network access, git operation, or pager/editor takeover.
+
 ## New Idea Prompt Editing
 
 The `n` prompt is a cursor-aware single-line title editor. When the dashboard scope is `★ All projects`, `n` first opens a concrete project picker (`j`/`k` or arrows to move, `Enter` to choose, `Esc` to cancel) so task capture never silently lands in the first registered project; if the first status snapshot has not arrived yet, the picker stays open in a loading state until projects are available. After a project is chosen, printable typing inserts at the title cursor; `←` / `→` move within the title; `Home` / `End` and `Ctrl+A` / `Ctrl+E` jump to the start/end; `Backspace` deletes before the cursor; `Delete` deletes under the cursor. Paste is accepted as either ordinary terminal text chunks or bracketed paste; CR/LF/TAB in pasted payloads are normalized to spaces because `hive new` takes a single title. The prompt keeps a conservative 4 KiB title buffer cap and flashes `title too long` instead of accepting oversized clipboard dumps.
@@ -186,7 +202,7 @@ Note that `REVIEW_STALE reason=wall_clock` deliberately retries even when no rev
 
 - **Resize:** Bubble Tea's runner installs its own SIGWINCH handler and synthesises a `WindowSizeMessage`; `BubbleModel#update` translates it into `Messages::WindowSized` so views can read `model.cols`/`model.rows` without poking the framework.
 - **Ctrl+Z / SIGTSTP:** Bubble Tea owns suspend/resume of the alt-screen and raw-mode toggling.
-- **SIGHUP:** trapped at boot in `App.run_charm`; the trap calls `runner.send(Messages::TERMINATE_REQUESTED)`, which the runner picks up at the top of the next loop tick. Update returns `Bubbletea.quit` so the runner exits cleanly. Cleanup runs in `App.run_charm`'s `ensure` (kill the polling thread, stop StateSource, restore the previous HUP handler, `SubprocessRegistry.kill_inflight!`, reap inflight auto-heal threads). All setup (StateSource boot, `Bubbletea::Runner` construction, HUP trap install, poller spawn) is performed *inside* the same `begin` so a constructor failure still hits the same nil-guarded cleanup path — the StateSource thread can no longer leak when `Bubbletea::Runner.new` raises.
+- **SIGHUP:** trapped at boot in `App.run_charm`; the trap calls `runner.send(Messages::TERMINATE_REQUESTED)`, which the runner picks up at the top of the next loop tick. Update returns `Bubbletea.quit` so the runner exits cleanly. Cleanup runs in `App.run_charm`'s `ensure` (kill the polling thread, stop StateSource, restore the previous HUP handler, `SubprocessRegistry.kill_inflight!`, reap tracked auto-heal/recovery/task-info workers). All setup (StateSource boot, `Bubbletea::Runner` construction, HUP trap install, poller spawn) is performed *inside* the same `begin` so a constructor failure still hits the same nil-guarded cleanup path — the StateSource thread can no longer leak when `Bubbletea::Runner.new` raises.
 - **Crash-time cleanup:** there is no `at_exit` hook. Workflow-verb children are spawned with `pgroup: true` and intentionally **detached** — `dispatch_background` never registers them with `SubprocessRegistry`, and the registry's `kill_inflight!` is called only from `App.run_charm`'s normal-exit `ensure` block (not from `at_exit`). A signal that bypasses that ensure (`SIGKILL` of the TUI, kernel OOM kill, etc.) leaves the children running. That is the design — long-running background agents outlive an interrupted dashboard so the user can re-attach with `hive tui` and pick up the in-flight rows. Recovery for kill-class markers landing on a re-launched TUI happens via `auto_heal_kill_class_errors`, not via at-exit cleanup.
 - **`--json`:** rejected at the command boundary with EX_USAGE (64); the TUI is human-only by design. The reject path emits a structured error envelope on stdout (`{"ok":false, "error_class":"InvalidTaskPath", "error_kind":"invalid_task_path", "exit_code":64, "message":...}`) so JSON consumers see typed error data without a `SCHEMA_VERSIONS` bump (the envelope intentionally omits `schema` because `hive tui` has no registered `hive-*` schema, and `error_kind` matches the value other `InvalidTaskPath` emit sites already use).
 - **Non-tty boundary:** running `hive tui` with `$stdout` not a tty (e.g., a piped CI invocation) raises `Hive::InvalidTaskPath` and exits 64 (EX_USAGE) — same code as `--json` rejection, so wrappers branch on a single "this is a misuse, not a software fault" surface.
@@ -195,9 +211,10 @@ 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`, `TaskInfo`, `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/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.
+- `test/e2e/scenarios/tui_info_panel.yml` — real tmux flow for the `[i] info` legend, asynchronous panel load, concrete absolute task/log paths, ignored input, both modal close paths, selection restoration, and read-only task artifacts.
 
 No render-layer snapshot tests beyond layout pinning; mainstream Ruby tooling does not provide cell-perfect terminal-snapshot diffing.
 
diff --git a/wiki/e2e.md b/wiki/e2e.md
index 486667ff..12f49a2f 100644
--- a/wiki/e2e.md
+++ b/wiki/e2e.md
@@ -3,7 +3,7 @@ title: Agentic E2E Suite
 type: reference
 source: test/e2e/, bin/hive-e2e, Rakefile
 created: 2026-04-29
-updated: 2026-04-29
+updated: 2026-07-23
 tags: [test, e2e, tui, artifacts]
 ---
 
@@ -39,7 +39,7 @@ Supported step kinds:
 - `json_assert`: run a CLI command, parse stdout, validate it against a `schemas/hive-*.json` file, then optionally assert a `pick` path.
 - `state_assert`: assert file existence, absence, marker state, substring, or regex match; supports a short timeout for async TUI updates.
 - `seed_state`, `write_file`, `register_project`, `ruby_block`: fixture setup escape hatches.
-- `tui_expect`, `tui_keys`, `wait_subprocess`: tmux-backed TUI interaction.
+- `tui_expect`, `tui_keys`, `wait_subprocess`, `wait_tui_exit`: tmux-backed TUI interaction. `wait_subprocess` observes a detached workflow child's run-scoped END marker; `wait_tui_exit` keeps the foreground pane after termination and accepts only a recorded zero exit status after grid-mode `q`.
 - `editor_action`, `log_assert`: narrower fixture helpers for editor/log flows.
 
 Template variables include `{sandbox}`, `{run_home}`, `{project}`, `{slug}`, `{run_id}`, and `{task_dir:<stage>}`.
@@ -89,6 +89,7 @@ On failure, the harness writes a scenario bundle containing:
 | `run_error_envelope` | `hive run --json` against a stale-locked task emits a parseable `hive-run` error payload. |
 | `stale_lock_recovery` | TEMPFAIL lock path, marker clear, rerun recovery. |
 | `tui_status_navigate_dispatch_plan` | TUI verb-key dispatch end-to-end: `p` on a ready-to-plan row spawns `bin/hive plan`, waits for the subprocess to exit, and asserts plan.md/COMPLETE landed. |
+| `tui_info_panel` | Full-screen task-info loading, concrete absolute task/log paths, modal no-op/dismissal behavior, selection restoration across two rows, unchanged task artifacts, and a verified clean TUI exit. |
 | `tui_new_idea_editing` | TUI new-idea prompt paste delivery plus cursor navigation/insertion before submit. |
 | `two_projects_fuzzy_filter` | tmux TUI filter input and project scope across two registered projects. |
 
@@ -98,6 +99,8 @@ The harness prepends repo `bin/` to the tmux environment PATH because TUI rows d
 
 `tmux` is required for TUI scenarios. `asciinema` is test-time optional until a TUI failure needs a cast, but missing/corrupt casts are recorded in artifacts instead of crashing unrelated CLI scenarios. If `asciinema` is installed outside PATH, set `HIVE_ASCIINEMA_BIN=/absolute/path/to/asciinema`.
 
+The tmux driver enables `remain-on-exit` for its scenario window. A dead pane passes only when `pane_dead_status` is zero; a non-zero status or a vanished session without an observable status fails the scenario instead of being treated as a successful TUI shutdown.
+
 ## Backlinks
 
 - [[testing]]
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 74218125..ec90a82a 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-07-23
 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. **Task-info help-copy alignment** — the shipped `i` interaction is now a full-screen task info panel, while the existing `?` help registry still describes the legacy bottom-strip idea preview. The implementing plan explicitly excluded `lib/hive/tui/help.rb`; align that copy in a follow-up without changing panel behavior.
 
 ## Release install follow-ups
 
diff --git a/wiki/log.md b/wiki/log.md
index 7c8465bc..c1c5cb3b 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -1855,3 +1855,27 @@ chruby and RVM are intentionally not handled — they modify PATH per-shell and
 
 **Refreshed pages:**
 - [[testing]] — documented the CI foreground/daemonization coverage pitfall and reload-safe enum caveat.
+
+## [2026-07-23T00:00:00Z] tui — full-screen task info panel
+
+**Action:** Replaced grid `i`'s bottom-strip idea preview with a full-screen, read-only task information mode. The grid enters a loading frame immediately; a tracked worker loads immutable common metadata plus the stage-specific brainstorm, plan, or execute-log snapshot. Stale request results are ignored, missing optional files stay explicit, file text is sanitized, overflow is visibly truncated without scrolling, and `q` / `Esc` / `i` restore the original selected task. The standard footer now includes `[?] help  [i] info  [q] quit`.
+
+**Refreshed pages:**
+- [[commands/tui]] — layout, mode/key contract, asynchronous loader lifecycle, stage content, and tests.
+- [[gaps]] — records the deliberately deferred `?` help-copy alignment.
+
+## [2026-07-23T15:00:00Z] tui — bound and harden task info rendering
+
+**Action:** Hardened the full-screen task-info panel against hostile and oversized file input. Shared TUI text sanitization now normalizes invalid byte sequences before applying terminal-control filters; idea, brainstorm, and plan reads stop at 256 KiB with a visible truncation marker; rendering sanitizes and wraps only the current terminal row budget; header overflow is truncated before Lipgloss styling; and closing cancels the active loader so rapid reopen cycles stay bounded. The task-info e2e fixture now uses two rows to prove selection restoration, distinguishes modal dismissal from a slug still visible in the panel, inventories all files for read-only verification, and waits for foreground TUI termination. A dedicated `wait_tui_exit` DSL step now gives every TUI scenario the same termination assertion without conflating foreground shutdown with detached workflow subprocess markers.
+
+**Refreshed pages:**
+- [[commands/tui]] — documented bounded reads, encoding tolerance, render budgeting, and single-loader cancellation.
+- [[e2e]] — documented `wait_tui_exit` and the strengthened task-info scenario.
+
+## [2026-07-23T16:00:00Z] tui — preserve complete info snapshots and verify clean shutdown
+
+**Action:** Corrected the task-info review follow-ups: idea, brainstorm, and plan snapshots now retain full content while the renderer alone enforces the visible row budget; asynchronous loads reject task folders that move stages; CJK and emoji wrap by terminal display cells; and TUI e2e shutdown waits retain the dead pane and reject non-zero or unobservable exit statuses. The task-info scenario now asserts the concrete absolute task-folder and latest-log paths.
+
+**Refreshed pages:**
+- [[commands/tui]] — full snapshot, moved-task validation, cell-aware layout, and concrete-path scenario coverage.
+- [[e2e]] — clean foreground exit-status verification.
