diff --git a/lib/hive/tui/bubble_model.rb b/lib/hive/tui/bubble_model.rb index d42249e3..b6172f6f 100644 --- a/lib/hive/tui/bubble_model.rb +++ b/lib/hive/tui/bubble_model.rb @@ -22,6 +22,7 @@ require "hive/tui/update" require "hive/tui/snapshot" require "hive/tui/text" require "hive/tui/log_tail" +require "hive/tui/info_panel" require "hive/tui/brainstorm_answers" require "hive/tui/clipboard" require "hive/tui/composer_staging" @@ -103,11 +104,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) }, + info_panel_loader: ->(row, request_id:) { Hive::Tui::InfoPanel.load(row, request_id: request_id) } ) @hive_model = hive_model @dispatch = dispatch @clipboard_probe = clipboard_probe + @info_panel_loader = info_panel_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 @@ -1515,41 +1518,38 @@ module Hive end def open_idea_preview(row) - return [ flashed("no idea for #{row.slug}"), nil ] if row.folder.to_s.empty? - - idea_path = File.join(row.folder, "idea.md") - return [ flashed("no idea.md for #{row.slug}"), nil ] unless File.exist?(idea_path) - - 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 ] - 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 - ), + new_model, = Hive::Tui::Update.apply( + @hive_model, + Hive::Tui::Messages::OpenIdeaPreview.new(row: row) + ) + request_id = new_model.info_panel_state.request_id + [ new_model, info_panel_load_command(row, request_id) ] + end + + # Background loader for the info panel. bubbletea-ruby only + # re-enters the model with Proc results that are Bubbletea::Message; + # Hive Data messages returned from a Proc are dropped. Match + # flash_async / recovery workers: push results through @dispatch + # (App wires runner.method(:send)) and return nil from the Proc. + def info_panel_load_command(row, request_id) + lambda do + state = @info_panel_loader.call(row, request_id: request_id) + message = if state.status == :error + Hive::Tui::Messages::InfoPanelFailed.new(state: state) + else + Hive::Tui::Messages::InfoPanelLoaded.new(state: state) + end + @dispatch.call(message) nil - ] - rescue Errno::ENOENT, Errno::EACCES, Psych::Exception - [ flashed("could not read idea for #{row.slug}"), nil ] - end - - def idea_frontmatter(contents) - match = contents.match(/\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\z)/m) - return {} unless match - - parsed = YAML.safe_load( - match[1], - permitted_classes: [ Time, Date ], - permitted_symbols: [], - aliases: false - ) || {} - parsed.is_a?(Hash) ? parsed : {} + rescue StandardError => e + state = Hive::Tui::Model::InfoPanelState.error( + row: row, + request_id: request_id, + error: "could not read info: #{Hive::Tui::InfoPanel.sanitize(e.message)}" + ) + @dispatch.call(Hive::Tui::Messages::InfoPanelFailed.new(state: state)) + nil + end end def resolve_agent_label(row) @@ -2886,7 +2886,7 @@ module Hive 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)) + Views::IdeaPreview.render(@hive_model, width: usable, height: @hive_model.rows) end # New-idea mode: same composition; footer = the inline prompt with @@ -2943,9 +2943,9 @@ module Hive cols = @hive_model.cols.to_i # Reserve a 1-cell right margin across every section so no row # ever lands a glyph in the terminal's last column. Header and - # footer strips are width-aware — the fixed hint footer was - # 75 chars regardless of terminal width and overflowed at - # cols<76 before this clamp. + # footer strips are width-aware — the fixed hint footer is + # 79 chars (pinned by tests) regardless of terminal width and + # would overflow at cols<80 without this clamp. usable = [ cols - 1, 1 ].max sections = [ header_strip(usable) ] sections << stalled_banner(usable) if stalled? @@ -3002,8 +3002,8 @@ module Hive # Default footer — context-aware key hints + flash decay (the # status line). v1 had this in Views::Grid#status_line; lifted here # so the panes stay layout-only. `usable_width` clamps the line - # so the fixed 75-char hint string doesn't overflow narrow - # terminals (e.g. cols=70 used to wrap onto a second visible row). + # so the fixed 79-char hint string (incl. `[i] info`) doesn't + # overflow narrow terminals (e.g. cols=70 wraps onto a second row). def default_footer(usable_width = nil) if @hive_model.flash_active? line = @hive_model.flash.to_s @@ -3017,7 +3017,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/info_panel.rb b/lib/hive/tui/info_panel.rb new file mode 100644 index 00000000..5354e625 --- /dev/null +++ b/lib/hive/tui/info_panel.rb @@ -0,0 +1,167 @@ +require "date" +require "yaml" +require "hive/task" +require "hive/tui/log_tail" +require "hive/tui/model" + +module Hive + module Tui + # Read-only, bounded data collection for the task information panel. + # The Bubble Tea command owns calling this module off the render/update + # path; views only consume the immutable InfoPanelState it returns. + module InfoPanel + READ_CAP_BYTES = 64 * 1024 + EXECUTE_TAIL_CAP_BYTES = 16 * 1024 + UNAVAILABLE = "unavailable".freeze + + ReadResult = Data.define(:text, :truncated) + LoadError = Class.new(StandardError) + + module_function + + def load(row, request_id:) + task = Hive::Task.new(row.folder) + idea = read_required(task, "idea.md") + frontmatter, body = parse_idea(idea.text) + original = frontmatter["original_text"] + original = body if original.nil? || original.to_s.empty? + + latest_log_path = latest_log_path(task) + extra_kind, extra_content, extra_truncated = stage_extra(task) + + Model::InfoPanelState.new( + request_id: request_id, + status: :loaded, + slug: row.slug.to_s, + stage: "#{task.stage_index}-#{task.stage_name}", + created_at: display_value(frontmatter["created_at"]), + original_text: sanitize(original.to_s), + task_folder: task.folder, + latest_log_path: latest_log_path, + extra_kind: extra_kind, + extra_content: extra_content, + original_truncated: idea.truncated, + extra_truncated: extra_truncated, + error: nil + ) + rescue Hive::InvalidTaskPath, LoadError, Psych::Exception, SystemCallError, IOError => e + Model::InfoPanelState.error( + row: row, + request_id: request_id, + error: "could not read info: #{sanitize(e.message)}" + ) + end + + def read_required(task, name) + path = File.join(task.folder, name) + raise LoadError, "#{name} is unavailable" unless File.file?(path) + + read_file(path, cap: READ_CAP_BYTES) + rescue *LogTail::FILESYSTEM_RESCUE => e + raise LoadError, "#{name} is unavailable (#{e.class.name.split('::').last})" + end + + def read_optional(path) + return [ UNAVAILABLE, false ] unless File.file?(path) + + result = read_file(path, cap: READ_CAP_BYTES) + [ result.text, result.truncated ] + rescue *LogTail::FILESYSTEM_RESCUE, IOError + [ UNAVAILABLE, false ] + end + + def stage_extra(task) + case task.stage_index + when 2 + content, truncated = read_optional(File.join(task.folder, "brainstorm.md")) + [ :brainstorm, content, truncated ] + when 3 + content, truncated = read_optional(File.join(task.folder, "plan.md")) + [ :plan, content, truncated ] + when 4 + execute_tail(task) + else + [ nil, nil, false ] + end + end + + def execute_tail(task) + path = LogTail::FileResolver.latest_matching(task.log_dir, "execute-*.log") + result = read_tail(path, cap: EXECUTE_TAIL_CAP_BYTES) + [ :execute_log, result.text, result.truncated ] + rescue Hive::NoLogFiles, *LogTail::FILESYSTEM_RESCUE, IOError + [ :execute_log, UNAVAILABLE, false ] + end + + def latest_log_path(task) + File.expand_path(LogTail::FileResolver.latest(task.log_dir)) + rescue Hive::NoLogFiles, *LogTail::FILESYSTEM_RESCUE + UNAVAILABLE + end + + def parse_idea(contents) + match = contents.match(/\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\z)/m) + raise LoadError, "idea.md has invalid frontmatter" unless match + + data = YAML.safe_load( + match[1], + permitted_classes: [ Time, Date ], + permitted_symbols: [], + aliases: false + ) || {} + raise LoadError, "idea.md frontmatter is not a mapping" unless data.is_a?(Hash) + + [ data.transform_keys(&:to_s), contents[match.end(0)..].to_s ] + rescue Psych::Exception => e + raise LoadError, "idea.md has invalid frontmatter (#{e.message})" + end + + # Read in binary mode and cap before conversion so a hostile or huge + # local file cannot allocate unbounded text in the Bubble Tea command. + def read_file(path, cap:) + File.open(path, "rb") do |file| + raw = file.read(cap + 1).to_s + truncated = raw.bytesize > cap + raw = raw.byteslice(0, cap) if truncated + ReadResult.new(text: sanitize(raw), truncated: truncated) + end + end + + def read_tail(path, cap:) + File.open(path, "rb") do |file| + size = file.size + start = [ size - cap, 0 ].max + file.seek(start) + raw = file.read(cap).to_s + ReadResult.new(text: sanitize(raw), truncated: start.positive?) + end + end + + # Files shown in the TUI are agent-authored, not terminal-trusted. + # Remove OSC/CSI escape sequences and remaining control bytes after + # transcoding so a log cannot rewrite a frame or poison its layout. + # + # Intentionally separate from Hive::Tui::Text.sanitize: that helper + # is single-line (status flashes / columns) and replaces every C0 + # control — including LF — with `?`. Panel bodies are multi-line, so + # this path preserves newlines, strips OSC as well as CSI, collapses + # CR/tab, and deletes residual controls. Do not "unify" callers onto + # Text.sanitize without a shared preserve_newlines: option, or panel + # layout will flatten into one line of `?`-punctuated garbage. + def sanitize(value) + text = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("�") + text = text.gsub(/\e\][^\a\e]*(?:\a|\e\\)/, "") + text = text.gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "") + text = text.delete("\e") + text = text.gsub("\r\n", "\n").gsub("\r", "\n").gsub("\t", " ") + text.gsub(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/, "") + end + + def display_value(value) + text = value.respond_to?(:iso8601) ? value.iso8601 : sanitize(value.to_s) + text = sanitize(text) + text.empty? ? UNAVAILABLE : text + end + end + end +end diff --git a/lib/hive/tui/key_map.rb b/lib/hive/tui/key_map.rb index 8233e9a4..8d6d3ef9 100644 --- a/lib/hive/tui/key_map.rb +++ b/lib/hive/tui/key_map.rb @@ -402,11 +402,13 @@ module Hive Messages::BACK end - # Idea preview is read-only: every key closes it and returns to - # grid, whether Bubble Tea emitted a printable String or a - # special-key Symbol. + # The info panel is read-only. It deliberately has a tight close-key + # contract so regular navigation, actions, and typed text cannot alter + # the grid while its asynchronous payload is on screen. def idea_preview_message(key:, row:) # rubocop:disable Lint/UnusedMethodArgument - Messages::BACK + return Messages::BACK if key == "q" || key == "i" || ESCAPE_KEYS.include?(key) + + Messages::NOOP end # New-idea prompt mode — same key shape as `:filter` mode but diff --git a/lib/hive/tui/log_tail.rb b/lib/hive/tui/log_tail.rb index a997fd5d..4d4fd7fc 100644 --- a/lib/hive/tui/log_tail.rb +++ b/lib/hive/tui/log_tail.rb @@ -155,12 +155,19 @@ module Hive # back to grid with a flash message instead of opening an # empty viewer. def latest(log_dir) - latest_in_dirs([ log_dir ]) + latest_matching(log_dir, "*.log") end - def latest_in_dirs(log_dirs) + # Narrow variant for read-only consumers that need a stage-specific + # log while preserving the same race-tolerant mtime selection used + # by the general tail view. + def latest_matching(log_dir, pattern) + latest_in_dirs([ log_dir ], pattern: pattern) + end + + 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 diff --git a/lib/hive/tui/messages.rb b/lib/hive/tui/messages.rb index 980952a5..5955dc20 100644 --- a/lib/hive/tui/messages.rb +++ b/lib/hive/tui/messages.rb @@ -179,13 +179,18 @@ 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. + # `i` in grid mode — open the read-only task information panel. + # Carries the row so BubbleModel can schedule its filesystem read + # after Update has painted the loading state. OpenIdeaPreview = Data.define(:row) + # The background info-panel command returns one of these typed + # results. Both carry the state (and therefore request identity), so + # Update can reject responses for a panel that has been closed or + # superseded by a newer request. + InfoPanelLoaded = Data.define(:state) + InfoPanelFailed = Data.define(:state) + # `s` in grid mode — suspend the TUI and open the focused row's # configured development agent in the feature worktree. BubbleModel # owns the marker flip and the foreground takeover here; the diff --git a/lib/hive/tui/model.rb b/lib/hive/tui/model.rb index b81a44c0..6241a37b 100644 --- a/lib/hive/tui/model.rb +++ b/lib/hive/tui/model.rb @@ -40,8 +40,11 @@ module Hive # asset. Resets only on open / cancel / submit. :new_idea_attachment_counter, :new_idea_broken_labels, # Array — labels highlighted after rich-submit validation fails - :idea_preview_text, # String or nil — original_text rendered in :idea_preview mode - :idea_preview_slug, # String or nil — slug captured when the preview opened + # :idea_preview mode still names the mode (KeyMap/Help/views), but + # panel payload lives only on info_panel_state — not legacy + # idea_preview_text / idea_preview_slug fields. + :info_panel_state, # Model::InfoPanelState or nil — :idea_preview mode only + :info_panel_request_counter, # Integer — monotonic identity for stale async result guards :flash, # String or nil — current status-line message :flash_set_at, # Time or nil — flash decay timestamp :tail_state, # Hive::Tui::LogTail::Tail or nil — :log_tail mode only @@ -104,6 +107,48 @@ module Hive end end + # Immutable payload for the read-only task information panel. The + # request id ties asynchronous filesystem responses to the panel that + # requested them, so stale work cannot overwrite a newer selection. + Model::InfoPanelState = Data.define( + :request_id, + :status, + :slug, + :stage, + :created_at, + :original_text, + :task_folder, + :latest_log_path, + :extra_kind, + :extra_content, + :original_truncated, + :extra_truncated, + :error + ) + class Model::InfoPanelState + def self.loading(row:, request_id:) + new( + request_id: request_id, + status: :loading, + slug: row.slug.to_s, + stage: row.stage.to_s, + created_at: nil, + original_text: nil, + task_folder: nil, + latest_log_path: nil, + extra_kind: nil, + extra_content: nil, + original_truncated: false, + extra_truncated: false, + error: nil + ) + end + + def self.error(row:, request_id:, error:) + loading(row: row, request_id: request_id).with(status: :error, error: error.to_s) + end + end + class Model # Boot state. App.run constructs the runner with this Model. # `pane_focus` defaults to `:right` so the table is the first @@ -128,8 +173,8 @@ module Hive new_idea_staging_tmp_root: nil, new_idea_attachment_counter: 0, new_idea_broken_labels: [], - idea_preview_text: nil, - idea_preview_slug: nil, + info_panel_state: nil, + info_panel_request_counter: 0, flash: nil, flash_set_at: nil, tail_state: nil, diff --git a/lib/hive/tui/update.rb b/lib/hive/tui/update.rb index f17a433b..28ef98a3 100644 --- a/lib/hive/tui/update.rb +++ b/lib/hive/tui/update.rb @@ -73,6 +73,10 @@ module Hive [ apply_show_help(model), nil ] when Messages::OpenFilterPrompt [ apply_open_filter_prompt(model), nil ] + when Messages::OpenIdeaPreview + [ apply_open_idea_preview(model, message), nil ] + when Messages::InfoPanelLoaded, Messages::InfoPanelFailed + [ apply_info_panel_result(model, message), nil ] when Messages::OpenRedStatusDetail [ apply_open_red_status_detail(model, message), nil ] when Messages::RedStatusDetailScroll @@ -713,6 +717,26 @@ module Hive model.with(mode: :red_status_detail, red_status_detail_state: state) end + # Open immediately, before BubbleModel schedules its local read. This + # one-frame loading state keeps the render loop responsive even if a + # task folder sits on a slow filesystem. + def apply_open_idea_preview(model, msg) + request_id = model.info_panel_request_counter.to_i + 1 + model.with( + mode: :idea_preview, + info_panel_state: Model::InfoPanelState.loading(row: msg.row, request_id: request_id), + info_panel_request_counter: request_id + ) + end + + def apply_info_panel_result(model, msg) + active = model.info_panel_state + return model unless model.mode == :idea_preview && active + return model unless active.request_id == msg.state.request_id + + model.with(info_panel_state: msg.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 +806,8 @@ 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 :idea_preview + model.with(mode: :grid, info_panel_state: nil) when :help, :filter then model.with(mode: :grid) when :new_idea_project then apply_new_idea_cancelled(model) else model diff --git a/lib/hive/tui/views/idea_preview.rb b/lib/hive/tui/views/idea_preview.rb index c9fdfb09..1a2bc351 100644 --- a/lib/hive/tui/views/idea_preview.rb +++ b/lib/hive/tui/views/idea_preview.rb @@ -1,58 +1,180 @@ +require "hive/tui/info_panel" 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. + # Full-screen, read-only task information panel. The historic module + # name remains because :idea_preview is an established internal mode; + # the user-facing surface is an info panel, not a footer preview. module IdeaPreview - DISMISS_HINT = "press any key to dismiss".freeze - MAX_VISIBLE_ROWS = 6 + DISMISS_HINT = "[q] / [Esc] / [i] close".freeze + ELLIPSIS = "…".freeze + + EXTRA_HEADINGS = { + brainstorm: "Brainstorm", + plan: "Plan", + execute_log: "Latest execute log" + }.freeze 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") + def render(model, width: model.cols.to_i, height: model.rows.to_i) + usable_width = [ width.to_i, 1 ].max + usable_height = [ height.to_i, 2 ].max + state = model.info_panel_state + rows = case state&.status + when :loading then loading_rows(state, usable_width) + when :error then error_rows(state, usable_width) + when :loaded then loaded_rows(state, usable_width) + else error_rows(nil, usable_width) + end + + extra_heading = state&.extra_kind && EXTRA_HEADINGS.fetch(state.extra_kind, "Additional info") + clip_rows(rows, usable_height - 1, extra_heading: extra_heading, width: usable_width) + .push(Styles::HINT.render(truncate(DISMISS_HINT, usable_width))).join("\n") end - def body_rows(text, width) - return [] if text.empty? + def loading_rows(state, width) + [ + header(state, width), + "", + truncate("Loading task info…", width) + ] + end - wrap_text(text, width).first(MAX_VISIBLE_ROWS).map { |line| truncate(line, width) } + def error_rows(state, width) + message = state&.error.to_s + message = "Task info is unavailable" if message.empty? + [ + header(state, width), + "", + *wrap_text(message, 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? + def loaded_rows(state, width) + rows = [ + header(state, width), + "", + *field_rows("Slug", state.slug, width), + *field_rows("Stage", state.stage, width), + *field_rows("Created", state.created_at, width), + *field_rows("Task folder", state.task_folder, width), + *field_rows("Latest log", state.latest_log_path, width), + "", + Styles::HEADER.render(truncate("Original idea", width)), + *content_rows(state.original_text, width) + ] + rows << ELLIPSIS if state.original_truncated - chunks = [] - offset = 0 - while offset < buffer.length - chunks << buffer[offset, capacity].to_s - offset += capacity + if state.extra_kind + rows.concat([ + "", + Styles::HEADER.render(truncate(EXTRA_HEADINGS.fetch(state.extra_kind, "Additional info"), width)), + *content_rows(state.extra_content, width) + ]) + rows << ELLIPSIS if state.extra_truncated end - chunks + rows + end + + def header(state, width) + slug = state&.slug.to_s + stage = state&.stage.to_s + title = [ "Task info", slug, stage ].reject(&:empty?).join(" · ") + Styles::HEADER.render(truncate(title, width)) + end + + def field_rows(label, value, width) + prefix = "#{label}: " + text = value.to_s + text = InfoPanel::UNAVAILABLE if text.empty? + value_width = [ width - prefix.length, 1 ].max + wrapped = wrap_text(text, value_width) + first = "#{prefix}#{wrapped.shift}" + return [ truncate(first, width) ] if wrapped.empty? + + [ truncate(first, width), *wrapped.map { |line| truncate("#{' ' * prefix.length}#{line}", width) } ] end + def content_rows(text, width) + value = text.to_s + value = InfoPanel::UNAVAILABLE if value.empty? + wrap_text(value, width) + end + + # Width-bounded word wrapping with a hard split for very long paths, + # URLs, or unbroken log tokens. The loader has already made text safe; + # this method is a pure terminal projection. def wrap_text(text, width) capacity = [ width.to_i, 1 ].max - text.each_line(chomp: true).flat_map do |line| - chunk_buffer(line, capacity) + text.to_s.split("\n", -1).flat_map do |paragraph| + wrap_paragraph(paragraph, capacity) + end + end + + def wrap_paragraph(paragraph, capacity) + return [ "" ] if paragraph.empty? + + words = paragraph.split(/\s+/) + rows = [] + line = +"" + words.each do |word| + chunks = chunk(word, capacity) + chunks.each_with_index do |part, index| + candidate = line.empty? ? part : "#{line} #{part}" + if candidate.length <= capacity + line = candidate + else + rows << line unless line.empty? + line = part + end + rows << line if index < chunks.length - 1 + line = +"" if index < chunks.length - 1 + end + end + rows << line unless line.empty? + rows + end + + def chunk(text, capacity) + return [ text ] if text.length <= capacity + + text.chars.each_slice(capacity).map(&:join) + end + + # Preserve the close hint as the final line. If a frame omits any + # content because of terminal height, its last visible content row is + # always a plain ellipsis rather than a silently cut-off field. + def clip_rows(rows, capacity, extra_heading: nil, width: nil) + return [ ELLIPSIS ] if capacity <= 1 && !rows.empty? + return rows if rows.length <= capacity + + # Keep the stage-extra heading visible even when the original idea + # alone would consume the remaining frame. The common metadata is + # intentionally emitted first; only after it is present do we trade + # surplus original-text rows for the heading that explains which + # stage artifact was loaded. + # + # Match the same truncated+styled string that loaded_rows stores — + # looking up HEADER.render(raw) fails when the heading itself was + # width-truncated (e.g. "Latest execute log" on cols < 18). + if extra_heading && capacity >= 3 + heading_width = width.nil? ? extra_heading.length : width + heading = Styles::HEADER.render(truncate(extra_heading, heading_width)) + heading_index = rows.index(heading) + if heading_index && heading_index >= capacity - 1 + return rows.first(capacity - 2) + [ heading, ELLIPSIS ] + end end + + rows.first(capacity - 1) + [ ELLIPSIS ] end def truncate(line, width) - Views::Format.truncate(line, width.to_i) + Views::Format.truncate(line.to_s, width.to_i) end end end diff --git a/test/e2e/scenarios/tui_two_pane_navigate.yml b/test/e2e/scenarios/tui_two_pane_navigate.yml index bbd4fa49..779390f0 100644 --- a/test/e2e/scenarios/tui_two_pane_navigate.yml +++ b/test/e2e/scenarios/tui_two_pane_navigate.yml @@ -15,6 +15,23 @@ steps: slug: tui-two-pane-task state_file: brainstorm.md content: "# Brainstorm\n\n\n" + files: + - path: idea.md + content: | + --- + slug: tui-two-pane-task + created_at: 2026-07-23T12:00:00Z + original_text: Improve the task information view + --- + + # Improve the task information view + - path: brainstorm.md + content: | + # Brainstorm + + Keep the info panel read-only and responsive. + + # TUI lazy-boots on the first tui_* step. Anchor against the v2 # header banner so we know the dashboard chrome rendered. @@ -31,6 +48,38 @@ steps: anchor: "All projects" timeout: 3 + # The footer advertises the info gesture. `i` opens a full-screen, + # read-only panel for the selected brainstorm task; its asynchronous local + # load still settles before the stable anchor assertions below. + - kind: tui_expect + anchor: "[i] info" + timeout: 3 + - kind: tui_keys + keys: "i" + - kind: tui_expect + anchor: "Task info" + timeout: 5 + - kind: tui_expect + anchor: "tui-two-pane-task" + timeout: 3 + - kind: tui_expect + anchor: "2-brainstorm" + timeout: 3 + - kind: tui_expect + anchor: "Improve the task information view" + timeout: 3 + - kind: tui_expect + anchor: "Keep the info panel read-only" + timeout: 3 + - kind: tui_keys + keys: "i" + - kind: tui_expect + anchor: "hive tui" + timeout: 3 + - kind: tui_expect + anchor: "tui-two-pane-task" + timeout: 3 + # Tab cycles pane focus. After the first Tab, focus moves to the # left pane; the projects pane renders with the focused border style. # We assert presence of the project name to confirm the left pane diff --git a/test/unit/tui/bubble_model_test.rb b/test/unit/tui/bubble_model_test.rb index bfc3e63b..a22d1af0 100644 --- a/test/unit/tui/bubble_model_test.rb +++ b/test/unit/tui/bubble_model_test.rb @@ -345,18 +345,29 @@ class HiveTuiBubbleModelTest < Minitest::Test assert_includes out, "/auth" end - def test_view_composes_idea_preview_onto_grid_in_idea_preview_mode + def test_view_renders_info_panel_without_the_grid_in_idea_preview_mode + row = Struct.new(:slug, :stage).new("some-slug", "2-brainstorm") + state = Hive::Tui::Model::InfoPanelState.loading(row: row, request_id: 1).with( + status: :loaded, + created_at: "2026-07-23T12:00:00Z", + original_text: "original idea", + task_folder: "/tmp/task", + latest_log_path: "/tmp/latest.log", + extra_kind: :brainstorm, + extra_content: "brainstorm notes" + ) @model = Hive::Tui::BubbleModel.new( hive_model: Hive::Tui::Model.initial.with( mode: :idea_preview, - idea_preview_slug: "some-slug", - idea_preview_text: "original idea" + info_panel_state: state ), dispatch: @dispatch ) out = @model.view - assert_includes out, "Idea for some-slug:" + assert_includes out, "Task info · some-slug · 2-brainstorm" assert_includes out, "original idea" + refute_includes out, "Projects" + refute_includes out, "Tasks ·" end # Regression: paste-truncated / paste-timeout / overflow flashes @@ -603,28 +614,24 @@ class HiveTuiBubbleModelTest < Minitest::Test refute_includes out, "[Enter] open", "Enter is not only an open action" end - def test_default_footer_hint_omits_o_at_70_col_budget - # Plan R6: `[o] open` is included in the footer only if it fits - # the 70-col budget without wrapping or pushing primary actions - # onto a second line. At 70 cols the current hint string is - # already ~69 chars; adding ten more (separator + "[o] open") - # would exceed the budget. We rely on the `?` overlay for - # discoverability instead. This test pins that decision so a - # future contributor doesn't silently re-add the hint and break - # 70-col rendering. + def test_default_footer_hint_includes_info_at_the_80_column_boundary hint = @model.send(:footer_hint) - assert_equal "[Tab] switch [Enter] action [n] new [/] filter [?] help [q] quit", + assert_equal "[Tab] switch [Enter] action [n] new [/] filter [?] help [i] info [q] quit", hint, - "footer hint must remain the pre-`o` literal; `o` is documented in `?` only" + "footer hint must advertise the read-only info panel" 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" + "the existing open-folder binding remains help-only" + assert_equal 79, hint.length + + normal = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial(cols: 80), dispatch: @dispatch + ).send(:default_footer, 79) + narrow = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial(cols: 70), dispatch: @dispatch + ).send(:default_footer, 69) + assert_includes normal, "[i] info" + assert_includes narrow, "…" + refute_includes narrow, "\n" end def test_grid_mode_collapses_to_single_pane_below_min_cols @@ -3658,118 +3665,170 @@ 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)) + # ---- OpenIdeaPreview → asynchronous info panel (read-only) ---- - 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 - end + def info_state(row, request_id:, text: "loaded") + Hive::Tui::Model::InfoPanelState.loading(row: row, request_id: request_id).with( + status: :loaded, + created_at: "2026-07-23T12:00:00Z", + original_text: text, + task_folder: "/tmp/task", + latest_log_path: "/tmp/latest.log" + ) end - def test_open_idea_preview_flashes_when_folder_empty - row = make_task_row(folder: "") + def test_open_info_panel_enters_loading_before_background_loader_runs + row = make_task_row + called = false + @model = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { called = true; info_state(row, request_id: request_id) } + ) _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) - assert_nil cmd - assert_equal :grid, @model.hive_model.mode - assert_match(/no idea for some-slug/, @model.hive_model.flash.to_s) - end - - def test_open_idea_preview_flashes_when_idea_md_missing - with_tmp_dir do |dir| - row = make_task_row(folder: dir) + assert_kind_of Proc, cmd + assert_equal :idea_preview, @model.hive_model.mode + assert_equal :loading, @model.hive_model.info_panel_state.status + refute called, "loader must run in Bubble Tea's background command, not during update" - _, 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 + result = cmd.call + assert_nil result, "Proc must return nil; Hive messages re-enter only via @dispatch" + assert called 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) + # Production App wires `dispatch: runner.method(:send)`. Returning a Hive + # message from the Proc is silently dropped by bubbletea-ruby; the load + # path must push InfoPanelLoaded/Failed through @dispatch so the model + # leaves :loading. This test pins that contract so unit tests cannot + # stay green while the real Charm loop stuck-loads forever. + def test_info_panel_load_command_dispatches_results_through_dispatch + row = make_task_row + @model = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { info_state(row, request_id: request_id, text: "via dispatch") } + ) - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + request_id = @model.hive_model.info_panel_state.request_id + assert_empty @messages, "dispatch must not fire until the background Proc runs" - 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 + result = cmd.call + assert_nil result + assert_equal 1, @messages.length + assert_kind_of Hive::Tui::Messages::InfoPanelLoaded, @messages.first + assert_equal request_id, @messages.first.state.request_id + assert_equal "via dispatch", @messages.first.state.original_text + + # Model stays :loading until the dispatched message is applied — the + # Proc return value is not the update channel. + assert_equal :loading, @model.hive_model.info_panel_state.status + @model.update(@messages.first) + assert_equal :loaded, @model.hive_model.info_panel_state.status + + @messages.clear + failing = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { raise "read failed #{request_id}" } + ) + _, failed_cmd = failing.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + failed_request_id = failing.hive_model.info_panel_state.request_id + assert_nil failed_cmd.call + assert_equal 1, @messages.length + assert_kind_of Hive::Tui::Messages::InfoPanelFailed, @messages.first + assert_equal failed_request_id, @messages.first.state.request_id 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) + def test_info_panel_loaded_and_failed_results_keep_panel_open + row = make_task_row + @model = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { info_state(row, request_id: request_id, text: "Async content") } + ) - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + assert_nil cmd.call + @model.update(@messages.last) + assert_equal :loaded, @model.hive_model.info_panel_state.status + assert_equal "Async content", @model.hive_model.info_panel_state.original_text - 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) - end + @messages.clear + failing = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { raise "read failed #{request_id}" } + ) + _, failed_cmd = failing.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + assert_nil failed_cmd.call + failed_result = @messages.last + assert_kind_of Hive::Tui::Messages::InfoPanelFailed, failed_result + failing.update(failed_result) + assert_equal :idea_preview, failing.hive_model.mode + assert_equal :error, failing.hive_model.info_panel_state.status + assert_match(/read failed/, failing.hive_model.info_panel_state.error) 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)) + def test_info_panel_close_keys_preserve_selection_and_other_keys_are_noops + row = make_task_row + # Drive close keys through KeyMap so the loop variable is exercised + # (KeyMap unit tests pin the mapping; this pins the BubbleModel path). + [ "q", "i", :key_escape ].each do |key| + opened = Hive::Tui::Model.initial.with(cursor: [ 2, 3 ], scope: 1, filter: "some") + panel = Hive::Tui::BubbleModel.new( + hive_model: opened, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { info_state(row, request_id: request_id) } + ) + panel.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + msg = Hive::Tui::KeyMap.message_for(mode: :idea_preview, key: key, row: nil) + assert_same Hive::Tui::Messages::BACK, msg, "#{key.inspect} must map to BACK" + panel.update(msg) - assert_nil cmd - assert_empty @messages - assert_equal before, File.read(idea_path) + assert_equal :grid, panel.hive_model.mode + assert_nil panel.hive_model.info_panel_state + assert_equal [ 2, 3 ], panel.hive_model.cursor + assert_equal 1, panel.hive_model.scope + assert_equal "some", panel.hive_model.filter end - end - def test_open_idea_preview_truncates_oversized_original_text - with_tmp_dir do |dir| - original = "x" * (Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS + 20) - write_idea_md(dir, original_text: original) - row = make_task_row(folder: dir) - - _, cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) - - assert_nil cmd - assert_equal :idea_preview, @model.hive_model.mode - assert_equal Hive::Tui::Model::NEW_IDEA_BUFFER_MAX_CHARS, - @model.hive_model.idea_preview_text.length - end + panel = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { info_state(row, request_id: request_id) } + ) + panel.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + before = panel.hive_model + noop = Hive::Tui::KeyMap.message_for(mode: :idea_preview, key: "x", row: nil) + assert_same Hive::Tui::Messages::NOOP, noop + panel.update(noop) + assert_same before, panel.hive_model 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)) - - assert_nil open_cmd - assert_equal :idea_preview, @model.hive_model.mode - assert_equal "Roundtrip idea", @model.hive_model.idea_preview_text + def test_late_info_results_after_close_or_reopen_are_ignored + row = make_task_row + @model = Hive::Tui::BubbleModel.new( + hive_model: Hive::Tui::Model.initial, + dispatch: @dispatch, + info_panel_loader: ->(_row, request_id:) { info_state(row, request_id: request_id) } + ) - _, dismiss_cmd = @model.update(Bubbletea::KeyMessage.new(key_type: 0, runes: [ "x".ord ])) + _, first_cmd = @model.update(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + assert_nil first_cmd.call + first_result = @messages.last + @messages.clear + @model.update(Hive::Tui::Messages::BACK) + @model.update(first_result) + assert_equal :grid, @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(Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + assert_equal 2, @model.hive_model.info_panel_state.request_id + @model.update(first_result) + assert_equal :loading, @model.hive_model.info_panel_state.status + assert_empty @messages end # ---- OpenInAgent → configured agent foreground takeover ---- diff --git a/test/unit/tui/info_panel_test.rb b/test/unit/tui/info_panel_test.rb new file mode 100644 index 00000000..053fda8a --- /dev/null +++ b/test/unit/tui/info_panel_test.rb @@ -0,0 +1,145 @@ +require "test_helper" +require "hive/tui/info_panel" +require "hive/tui/snapshot" + +class HiveTuiInfoPanelTest < Minitest::Test + include HiveTestHelper + + def test_inbox_loads_common_fields_without_stage_extra + with_task("1-inbox") do |task_dir, row, log_dir| + write_idea(task_dir, original_text: "Capture the operator request") + log_path = File.join(log_dir, "inbox.log") + File.write(log_path, "captured\n") + + state = Hive::Tui::InfoPanel.load(row, request_id: 7) + + assert_equal :loaded, state.status + assert_equal row.slug, state.slug + assert_equal "1-inbox", state.stage + assert_equal "2026-07-23T12:00:00Z", state.created_at + assert_equal "Capture the operator request", state.original_text + assert_equal File.expand_path(task_dir), state.task_folder + assert_equal File.expand_path(log_path), state.latest_log_path + assert_nil state.extra_kind + assert_nil state.extra_content + end + end + + def test_brainstorm_and_plan_stages_load_their_stage_documents + { "2-brainstorm" => [ "brainstorm.md", :brainstorm, "Explore alternatives" ], + "3-plan" => [ "plan.md", :plan, "Implement the agreed approach" ] }.each do |stage, (filename, kind, content)| + with_task(stage) do |task_dir, row, _log_dir| + write_idea(task_dir, original_text: "Seed idea") + File.write(File.join(task_dir, filename), content) + + state = Hive::Tui::InfoPanel.load(row, request_id: 1) + + assert_equal kind, state.extra_kind + assert_equal content, state.extra_content + end + end + end + + def test_execute_uses_newest_execute_log_for_extra_and_newest_log_for_common_path + with_task("4-execute") do |task_dir, row, log_dir| + write_idea(task_dir, original_text: "Ship the feature") + old_execute = File.join(log_dir, "execute-01.log") + new_execute = File.join(log_dir, "execute-02.log") + newest_other = File.join(log_dir, "review-01.log") + File.write(old_execute, "old execute") + File.write(new_execute, "new execute tail") + File.write(newest_other, "newest overall") + File.utime(Time.now - 30, Time.now - 30, old_execute) + File.utime(Time.now - 20, Time.now - 20, new_execute) + File.utime(Time.now - 10, Time.now - 10, newest_other) + + state = Hive::Tui::InfoPanel.load(row, request_id: 1) + + assert_equal File.expand_path(newest_other), state.latest_log_path + assert_equal :execute_log, state.extra_kind + assert_equal "new execute tail", state.extra_content + end + end + + def test_missing_optional_sources_are_unavailable_without_dropping_common_fields + with_task("2-brainstorm") do |task_dir, row, _log_dir| + write_idea(task_dir, original_text: "Keep common values") + + state = Hive::Tui::InfoPanel.load(row, request_id: 1) + + assert_equal :loaded, state.status + assert_equal "Keep common values", state.original_text + assert_equal Hive::Tui::InfoPanel::UNAVAILABLE, state.latest_log_path + assert_equal :brainstorm, state.extra_kind + assert_equal Hive::Tui::InfoPanel::UNAVAILABLE, state.extra_content + end + end + + def test_missing_or_malformed_idea_returns_error_state_without_raising + with_task("1-inbox") do |task_dir, row, _log_dir| + missing = Hive::Tui::InfoPanel.load(row, request_id: 1) + assert_equal :error, missing.status + assert_match(/idea\.md/i, missing.error) + + File.write(File.join(task_dir, "idea.md"), "---\noriginal_text: [broken\n---\n") + malformed = Hive::Tui::InfoPanel.load(row, request_id: 2) + assert_equal :error, malformed.status + assert_match(/idea\.md/i, malformed.error) + end + end + + def test_sanitizes_hostile_content_marks_caps_and_does_not_mutate_sources + with_task("2-brainstorm") do |task_dir, row, log_dir| + idea_path = write_idea(task_dir, original_text: "safe\e[31m red\x00") + stage_path = File.join(task_dir, "brainstorm.md") + File.binwrite(stage_path, ("x" * (Hive::Tui::InfoPanel::READ_CAP_BYTES + 1)).b + "\e]0;title\a\xff") + log_path = File.join(log_dir, "latest.log") + File.write(log_path, "latest") + before = [ idea_path, stage_path, log_path ].to_h { |path| [ path, [ File.binread(path), File.mtime(path) ] ] } + + state = Hive::Tui::InfoPanel.load(row, request_id: 1) + + assert_equal "safe red", state.original_text + assert state.extra_truncated + refute_match(/\e|\x00/, state.extra_content) + before.each do |path, (contents, mtime)| + assert_equal contents, File.binread(path) + assert_equal mtime, File.mtime(path) + end + end + end + + private + + def with_task(stage) + with_tmp_dir do |root| + slug = "info-card" + task_dir = File.join(root, ".hive-state", "stages", stage, slug) + log_dir = File.join(root, ".hive-state", "logs", slug) + FileUtils.mkdir_p(task_dir) + FileUtils.mkdir_p(log_dir) + row = Hive::Tui::Snapshot::Row.new( + project_name: "demo", stage: stage, slug: slug, folder: task_dir, + state_file: File.join(task_dir, "idea.md"), marker: "waiting", attrs: {}, mtime: nil, + age_seconds: 0, claude_pid: nil, claude_pid_alive: nil, + action_key: "ready", action_label: "Ready", suggested_command: nil, + next_action: nil, diagnostic: nil + ) + yield task_dir, row, log_dir + end + end + + def write_idea(task_dir, original_text:) + path = File.join(task_dir, "idea.md") + File.write(path, <<~IDEA) + --- + slug: info-card + created_at: 2026-07-23T12:00:00Z + original_text: #{original_text.inspect} + --- + + #{original_text} + IDEA + path + end +end diff --git a/test/unit/tui/key_map_test.rb b/test/unit/tui/key_map_test.rb index d58b283e..ee4d0541 100644 --- a/test/unit/tui/key_map_test.rb +++ b/test/unit/tui/key_map_test.rb @@ -258,13 +258,20 @@ class TuiKeyMapMessageForTest < Minitest::Test assert_equal "i", msg.char end - def test_idea_preview_any_key_returns_back - [ "i", "x", :key_enter, :key_escape, "q", :space ].each do |key| + def test_idea_preview_only_close_keys_return_back + [ "i", :key_escape, "q" ].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" end end + def test_idea_preview_ignores_non_close_keys + [ "x", :key_enter, :key_down, :space ].each do |key| + msg = Hive::Tui::KeyMap.message_for(mode: :idea_preview, key: key, row: nil) + assert_same Hive::Tui::Messages::NOOP, msg, "#{key.inspect} must not dismiss info panel" + end + end + def test_log_tail_o_is_noop # Mode isolation R7: in :log_tail mode the `o` key falls through # to NOOP (only q/Esc are bound — back to grid). Pins that diff --git a/test/unit/tui/log_tail_test.rb b/test/unit/tui/log_tail_test.rb index c0939a8b..050e05e2 100644 --- a/test/unit/tui/log_tail_test.rb +++ b/test/unit/tui/log_tail_test.rb @@ -108,6 +108,20 @@ class TuiLogTailTest < Minitest::Test end end + def test_latest_matching_limits_candidates_before_selecting_newest_mtime + with_log_dir do |dir| + execute = File.join(dir, "execute-01.log") + newer_review = File.join(dir, "review-01.log") + File.write(execute, "execute\n") + File.write(newer_review, "review\n") + File.utime(Time.now - 10, Time.now - 10, execute) + File.utime(Time.now, Time.now, newer_review) + + assert_equal execute, + Hive::Tui::LogTail::FileResolver.latest_matching(dir, "execute-*.log") + end + end + def test_latest_raises_no_log_files_on_empty_directory with_log_dir do |dir| err = assert_raises(Hive::NoLogFiles) { Hive::Tui::LogTail::FileResolver.latest(dir) } diff --git a/test/unit/tui/messages_test.rb b/test/unit/tui/messages_test.rb index 322dce55..24b0bfb5 100644 --- a/test/unit/tui/messages_test.rb +++ b/test/unit/tui/messages_test.rb @@ -67,6 +67,14 @@ class HiveTuiMessagesTest < Minitest::Test assert_includes Hive::Tui::Messages::OpenIdeaPreview.members, :row end + def test_info_panel_result_messages_carry_immutable_state + row = Struct.new(:slug, :stage).new("info-card", "2-brainstorm") + state = Hive::Tui::Model::InfoPanelState.loading(row: row, request_id: 3) + + assert_same state, Hive::Tui::Messages::InfoPanelLoaded.new(state: state).state + assert_same state, Hive::Tui::Messages::InfoPanelFailed.new(state: state).state + end + def test_open_in_agent_carries_row row = Object.new msg = Hive::Tui::Messages::OpenInAgent.new(row: row) diff --git a/test/unit/tui/model_test.rb b/test/unit/tui/model_test.rb index efcf5716..66fd9469 100644 --- a/test/unit/tui/model_test.rb +++ b/test/unit/tui/model_test.rb @@ -26,8 +26,8 @@ class HiveTuiModelTest < Minitest::Test assert_nil model.new_idea_staging_dir assert_nil model.new_idea_staging_tmp_root assert_equal [], model.new_idea_broken_labels - assert_nil model.idea_preview_text - assert_nil model.idea_preview_slug + assert_nil model.info_panel_state + assert_equal 0, model.info_panel_request_counter assert_nil model.flash assert_nil model.flash_set_at assert_nil model.tail_state @@ -65,14 +65,14 @@ class HiveTuiModelTest < Minitest::Test assert_equal 2, b.scope end - def test_with_updates_idea_preview_fields + def test_with_updates_info_panel_state a = Hive::Tui::Model.initial - b = a.with(idea_preview_text: "original idea", idea_preview_slug: "ship-preview") + row = Struct.new(:slug, :stage).new("ship-preview", "1-inbox") + state = Hive::Tui::Model::InfoPanelState.loading(row: row, request_id: 1) + b = a.with(info_panel_state: state) - assert_nil a.idea_preview_text - assert_nil a.idea_preview_slug - assert_equal "original idea", b.idea_preview_text - assert_equal "ship-preview", b.idea_preview_slug + assert_nil a.info_panel_state + assert_equal state, b.info_panel_state refute_same a, b end @@ -129,7 +129,7 @@ class HiveTuiModelTest < Minitest::Test expected = %i[mode snapshot cursor filter filter_buffer scope pane_focus new_idea_project_name new_idea_project_cursor new_idea_buffer new_idea_cursor new_idea_attachments new_idea_staging_dir new_idea_staging_tmp_root new_idea_attachment_counter - new_idea_broken_labels idea_preview_text idea_preview_slug flash flash_set_at + new_idea_broken_labels info_panel_state info_panel_request_counter flash flash_set_at tail_state red_status_detail_state cols rows last_error] assert_equal expected, Hive::Tui::Model.members diff --git a/test/unit/tui/update_test.rb b/test/unit/tui/update_test.rb index 53fc23ab..a0253fc6 100644 --- a/test/unit/tui/update_test.rb +++ b/test/unit/tui/update_test.rb @@ -138,6 +138,58 @@ class HiveTuiUpdateTest < Minitest::Test assert_equal "codex", new_model.red_status_detail_state.agent_label end + def test_open_info_panel_enters_loading_state_without_changing_cursor_or_scope + row = red_detail_row + starting = model.with(cursor: [ 2, 4 ], scope: 2, filter: "red") + + opened, cmd = Hive::Tui::Update.apply( + starting, + Hive::Tui::Messages::OpenIdeaPreview.new(row: row) + ) + + assert_nil cmd + assert_equal :idea_preview, opened.mode + assert_equal :loading, opened.info_panel_state.status + assert_equal 1, opened.info_panel_state.request_id + assert_equal [ 2, 4 ], opened.cursor + assert_equal 2, opened.scope + assert_equal "red", opened.filter + end + + def test_info_panel_results_only_apply_to_matching_open_request + row = red_detail_row + opened, = Hive::Tui::Update.apply(model, Hive::Tui::Messages::OpenIdeaPreview.new(row: row)) + loaded = opened.info_panel_state.with(status: :loaded, original_text: "loaded") + + applied, = Hive::Tui::Update.apply(opened, Hive::Tui::Messages::InfoPanelLoaded.new(state: loaded)) + assert_equal :loaded, applied.info_panel_state.status + + stale = loaded.with(request_id: 99, original_text: "stale") + ignored, = Hive::Tui::Update.apply(applied, Hive::Tui::Messages::InfoPanelLoaded.new(state: stale)) + assert_same applied, ignored + + closed, = Hive::Tui::Update.apply(applied, Hive::Tui::Messages::BACK) + after_close, = Hive::Tui::Update.apply(closed, Hive::Tui::Messages::InfoPanelFailed.new(state: loaded)) + assert_same closed, after_close + end + + def test_back_from_info_panel_clears_only_info_state + row = red_detail_row + opened, = Hive::Tui::Update.apply( + model.with(cursor: [ 1, 1 ], scope: 1, filter: "red"), + Hive::Tui::Messages::OpenIdeaPreview.new(row: row) + ) + + closed, cmd = Hive::Tui::Update.apply(opened, Hive::Tui::Messages::BACK) + + assert_nil cmd + assert_equal :grid, closed.mode + assert_nil closed.info_panel_state + assert_equal [ 1, 1 ], closed.cursor + assert_equal 1, closed.scope + assert_equal "red", closed.filter + end + def test_open_red_status_detail_defaults_agent_label_when_missing # No resolver result (BubbleModel rescued a corrupt config) — the # state falls back to RedStatusDetailState::AGENT_FALLBACK so the @@ -1338,24 +1390,23 @@ class HiveTuiUpdateTest < Minitest::Test assert_equal :grid, new_model.mode end - def test_back_from_idea_preview_clears_text_and_returns_to_grid + def test_back_from_idea_preview_clears_info_panel_state_and_returns_to_grid + row = red_detail_row starting = model.with( mode: :idea_preview, - idea_preview_text: "original idea", - idea_preview_slug: "some-slug" + info_panel_state: Hive::Tui::Model::InfoPanelState.loading(row: row, request_id: 1) ) new_model, _cmd = Hive::Tui::Update.apply(starting, Hive::Tui::Messages::BACK) assert_equal :grid, new_model.mode - assert_nil new_model.idea_preview_text - assert_nil new_model.idea_preview_slug + assert_nil new_model.info_panel_state end def test_back_from_idea_preview_preserves_cursor_and_scope + row = red_detail_row starting = model.with( mode: :idea_preview, - idea_preview_text: "original idea", - idea_preview_slug: "some-slug", + info_panel_state: Hive::Tui::Model::InfoPanelState.loading(row: row, request_id: 1), cursor: [ 1, 2 ], scope: 2 ) diff --git a/test/unit/tui/views/idea_preview_test.rb b/test/unit/tui/views/idea_preview_test.rb index b18b8def..b81440a3 100644 --- a/test/unit/tui/views/idea_preview_test.rb +++ b/test/unit/tui/views/idea_preview_test.rb @@ -5,55 +5,112 @@ 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 + def panel_state(status: :loaded, stage: "2-brainstorm", extra_kind: :brainstorm, + extra_content: "Explore alternatives", original: "Original idea", **overrides) + row = Struct.new(:slug, :stage).new("some-slug", stage) + Hive::Tui::Model::InfoPanelState.loading(row: row, request_id: 1).with( + status: status, + created_at: "2026-07-23T12:00:00Z", + original_text: original, + task_folder: "/tmp/project/.hive-state/stages/#{stage}/some-slug", + latest_log_path: "/tmp/project/.hive-state/logs/some-slug/latest.log", + extra_kind: extra_kind, + extra_content: extra_content, + **overrides ) end - def render_lines(**kwargs) - Hive::Tui::Views::IdeaPreview.render(model_with(**kwargs), width: kwargs.fetch(:cols, 80)).lines(chomp: true) + def model_with(state: panel_state, cols: 80, rows: 24) + Hive::Tui::Model.initial(cols: cols, rows: rows).with(mode: :idea_preview, info_panel_state: state) 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:" + def render_lines(state: panel_state, cols: 80, rows: 24) + Hive::Tui::Views::IdeaPreview.render(model_with(state: state, cols: cols, rows: rows), width: cols, height: rows) + .lines(chomp: true) 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_loaded_panel_renders_all_common_fields_and_original_idea + out = render_lines.join("\n") - 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" + assert_includes out, "Task info · some-slug · 2-brainstorm" + assert_includes out, "Slug: some-slug" + assert_includes out, "Stage: 2-brainstorm" + assert_includes out, "Created: 2026-07-23T12:00:00Z" + assert_includes out, "Task folder: /tmp/project/.hive-state/stages/2-brainstorm/some-slug" + assert_includes out, "Latest log: /tmp/project/.hive-state/logs/some-slug/latest.log" + assert_includes out, "Original idea" + assert_includes out, "Original idea" end - def test_truncates_long_lines_to_width - lines = render_lines(text: "x" * 80, cols: 20) + def test_stage_extras_have_distinct_headings_and_common_only_stages_omit_them + brainstorm = render_lines(state: panel_state).join("\n") + plan = render_lines(state: panel_state(stage: "3-plan", extra_kind: :plan, extra_content: "Plan text")).join("\n") + execute = render_lines(state: panel_state(stage: "4-execute", extra_kind: :execute_log, extra_content: "Log tail")).join("\n") + inbox = render_lines(state: panel_state(stage: "1-inbox", extra_kind: nil, extra_content: nil)).join("\n") + later = render_lines(state: panel_state(stage: "6-review", extra_kind: nil, extra_content: nil)).join("\n") - assert lines.all? { |line| line.length <= 20 }, - "all rendered lines must fit width: #{lines.inspect}" + assert_includes brainstorm, "Brainstorm" + assert_includes plan, "Plan" + assert_includes execute, "Latest execute log" + refute_includes inbox, "Brainstorm" + refute_includes later, "Latest execute log" 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] + def test_loading_and_error_states_are_full_panel_content_with_close_hint + loading = render_lines(state: panel_state(status: :loading, original: nil, extra_kind: nil, extra_content: nil)) + error = render_lines(state: panel_state(status: :error, original: nil, extra_kind: nil, extra_content: nil, + error: "idea.md is unavailable")) - assert_operator body_lines.length, :<=, Hive::Tui::Views::IdeaPreview::MAX_VISIBLE_ROWS + assert_includes loading.join("\n"), "Loading task info…" + assert_includes error.join("\n"), "idea.md is unavailable" + assert_equal Hive::Tui::Views::IdeaPreview::DISMISS_HINT, loading.last + assert_equal Hive::Tui::Views::IdeaPreview::DISMISS_HINT, error.last end - def test_handles_nil_text_gracefully - lines = render_lines(text: nil, slug: "nil-text") + def test_wraps_to_width_and_clips_height_with_ellipsis_before_close_hint + state = panel_state( + original: ("a long idea line " * 20), + extra_content: ("a long brainstorm line " * 20), + original_truncated: true + ) + lines = render_lines(state: state, cols: 24, rows: 12) - assert_equal 2, lines.length - assert_includes lines.first, "Idea for nil-text:" + assert_operator lines.length, :<=, 12 + assert lines.all? { |line| line.length <= 24 }, "all lines must fit width: #{lines.inspect}" + assert_includes lines, "Brainstorm" + assert_equal "…", lines[-2] assert_equal Hive::Tui::Views::IdeaPreview::DISMISS_HINT, lines.last end + + def test_narrow_width_still_reserves_truncated_stage_extra_heading + # "Latest execute log" is 18 chars; below that, loaded_rows stores a + # truncated HEADER string. clip_rows must match that same string so the + # reserved-heading path still runs on narrow terminals. + width = 12 + state = panel_state( + stage: "4-execute", + extra_kind: :execute_log, + original: ("original idea word " * 40), + extra_content: "execute tail", + original_truncated: true + ) + lines = render_lines(state: state, cols: width, rows: 10) + expected_heading = Hive::Tui::Views::Format.truncate( + Hive::Tui::Views::IdeaPreview::EXTRA_HEADINGS.fetch(:execute_log), + width + ) + + assert_operator lines.length, :<=, 10 + assert lines.all? { |line| line.length <= width }, "all lines must fit width: #{lines.inspect}" + assert_includes lines, expected_heading, + "truncated stage-extra heading must stay visible after clip: #{lines.inspect}" + assert_equal "…", lines[-2] + end + + def test_unavailable_optional_values_are_rendered_stably + out = render_lines(state: panel_state(extra_content: "unavailable")).join("\n") + + assert_includes out, "Brainstorm" + assert_includes out, "unavailable" + end end diff --git a/wiki/architecture.md b/wiki/architecture.md index 48d85311..ec656808 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -142,6 +142,7 @@ bin/hive tui → Hive::Tui::App.run_charm ### Key seams - **Side-effect seam** — `BubbleModel#handle_side_effect` returns `[new_model, cmd]` to short-circuit `Update.apply`, or `nil` to fall through. This is the single line where impurity is allowed; everything else flows through `Update.apply`. +- **Async re-entry via `@dispatch`** — App wires `dispatch: runner.method(:send)`. Background Proc commands (info-panel load, flash_async recovery workers, input-editor exit messages) must `@dispatch.call(Hive message)` and return `nil`. Returning a Hive `Data` message from a Proc is silently dropped by bubbletea-ruby (only `Bubbletea::Message` results re-enter). The info panel (`:idea_preview` mode) loads via this path; panel payload lives on `Model#info_panel_state` only. - **Paste routing-by-mode** — `InputDecoder` emits a `Messages::RawTextInput(text:, paste:)` for any text-bearing chunk; `BubbleModel#translate_raw_text_input` rewrites it to `NewIdeaTextInserted` / `FilterTextInserted` based on `model.mode`, so a paste landing in `:grid` mode never mutates a hidden prompt buffer. - **Decoder reset on cancel** — `PasteAwareRunner` watches for transitions away from the previous editable mode (`:new_idea` / `:filter`, including `:new_idea ↔ :filter` jumps) and calls `InputDecoder#reset!` so an orphan paste held mid-prompt cannot dump into the next prompt. - **GVL yielding** — bubbletea-ruby's input reader holds the GVL for the full `input_timeout`; `BubbleModel` schedules a recurring 10 ms `YieldTick` whose handler calls `Thread.pass`, keeping the StateSource poller and other background threads alive. See `docs/solutions/2026-04-27-charm-bubbletea-api-gaps.md`. diff --git a/wiki/commands/tui.md b/wiki/commands/tui.md index f46a0504..d2cedd1a 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: [Tab] switch [Enter] action [n] new [/] 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 information panel | `i` on a selected task row in the tasks pane | `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) | @@ -66,6 +67,7 @@ Pane focus is keyboard-only; the focused pane border is bright cyan, the inactiv | `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). | | `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 | +| `i` | open a full-screen, read-only information panel for the focused task row. It opens in a loading state and reads local task files asynchronously; it never dispatches a workflow or writes task state. | | `n` | open the new-idea flow; if scope is `★ All projects`, first show a project picker, then submit with `hive new ""` against the chosen concrete project | | `/` | open filter prompt | | `1`–`9` | scope the right pane to the Nth registered project (mirrors selection in the left pane) | @@ -75,6 +77,12 @@ Pane focus is keyboard-only; the focused pane border is bright cyan, the inactiv | `q` | quit (default mode) | | `Esc` | back to default mode (any sub-mode) | +### Task information panel + +The `i` panel is a bounded projection of the selected task, not an editor or a log viewer. It shows the slug, stage, idea `created_at`, original idea text (falling back to the Markdown body when the frontmatter field is absent), absolute task-folder path, and absolute path of the newest task log. `2-brainstorm` adds `brainstorm.md`, `3-plan` adds `plan.md`, and `4-execute` adds the tail of the newest `execute-*.log`; `1-inbox` and later stages show only the common fields. Missing optional documents/logs render as `unavailable`; missing or malformed `idea.md` remains inside a read-only error panel. + +The panel is intentionally non-scrolling in this iteration. It wraps safe display text to the terminal width, reserves the close hint as the last line, and marks omitted content with `…`. Only `q`, `Esc`, and `i` close it; every other key is inert, and closing returns to the same grid selection. + 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. ## New Idea Prompt Editing @@ -194,8 +202,8 @@ Note that `REVIEW_STALE reason=wall_clock` deliberately retries even when no rev ## Test surface - `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/*_test.rb` — pure-Ruby state machines (`StateSource`, `Snapshot`, `KeyMap`, `GridState`, `InfoPanel`, `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`, `IdeaPreview`, `NewIdeaPrompt`). Layout/text content is pinned; visual styling (color/bold/reverse) is validated by manual dogfood — lipgloss-ruby v0.2.2 strips ANSI in non-tty test environments (gap tracked in `docs/solutions/2026-04-27-charm-bubbletea-api-gaps.md`). Selection / cursor highlight predicates (`ProjectsPane#selected?`, `TasksPane#highlight?`) are exposed for unit-test assertion since the rendered output cannot distinguish them in non-tty. - `test/integration/tui_subprocess_test.rb` — `Subprocess.takeover_command` / `run_quiet!` against a fake child binary. - `test/integration/tui_smoke_test.rb` + `test/integration/tui_smoke_charm_test.rb` — PTY-based boot smokes: `bin/hive tui` paints, the seeded project name appears, `q` exits 0. diff --git a/wiki/log.md b/wiki/log.md index 7c8465bc..0d6c7050 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2,6 +2,13 @@ Append-only log of all wiki operations. +## [2026-07-23T00:00:00Z] tui — info-panel async load re-enters via @dispatch (review fix pass 1) + +**Action:** Stage 6-review pass-1 auto-fixes for the full-screen task info panel (`i` key / `:idea_preview` mode). Background `info_panel_load_command` now `@dispatch.call(InfoPanelLoaded|Failed)` (return nil) so bubbletea-ruby re-enters the model — returning Hive messages from a Proc was silently dropped and the panel stayed on `:loading`. Tests assert the dispatch contract. Stage-extra heading reservation in `Views::IdeaPreview#clip_rows` matches the same width-truncated+styled string as `loaded_rows`. Dead `idea_preview_text` / `idea_preview_slug` model fields removed. `InfoPanel.sanitize` documents why it diverges from `Text.sanitize` (multiline/OSC). + +**Refreshed pages:** +- [[architecture]] — Key seams: async re-entry via `@dispatch`. + ## [2026-05-23T11:30:00Z] drop — pass-1 + pass-2 review-finding fixes hardened hard-delete **Action:** Recorded the two follow-up fix passes against `hive drop` after the initial feat/U1+U3 commit. Pass-1 (24 findings) and pass-2 (48 findings) tightened idempotency, PID-reuse safety, locale-stable git stderr parsing, worktree-pointer root validation, malformed-YAML rescue in `Worktree.read_pointer`, daemon-row `folder_missing_nil` distinction, and a closed `commit_action` enum on the drop schema. The `9-done` refusal prose in [[commands/drop]] was folded into the refusals-table caption. [[cli]] is already in sync (drop row + exit-code/`--json` envelope row). Schema enum + `holder`/`lock_path` extras were aligned with `DropErrorKind` during pass-1. @@ -1855,3 +1862,10 @@ chruby and RVM are intentionally not handled — they modify PATH per-shell and **Refreshed pages:** - [[testing]] — documented the CI foreground/daemonization coverage pitfall and reload-safe enum caveat. + +## [2026-07-23T00:00:00Z] tui — full-screen read-only task information panel + +**Action:** Documented the `i`-key information panel. The grid footer now advertises `[i] info`; opening it paints a loading screen immediately and completes a bounded local filesystem read asynchronously. The loaded view carries the task identity, stage, idea creation time and original text, absolute task-folder and newest-log paths, plus the brainstorm/plan/execute stage extra where applicable. It is strictly read-only: no workflow dispatch, marker mutation, git operation, network call, or file write occurs. `q`, `Esc`, and `i` close the panel without moving the grid selection; other keys are inert. Long content wraps and clips with `…`; scrolling is deliberately out of scope. + +**Refreshed pages:** +- [[commands/tui]] — layout footer, mode/key tables, info-panel behavior and boundaries, and unit/view test surface.