Skip to content

feat(cli): add Rich human-readable output to bm tool commands#967

Open
groksrc wants to merge 11 commits into
mainfrom
feat/678-rich-tool-output
Open

feat(cli): add Rich human-readable output to bm tool commands#967
groksrc wants to merge 11 commits into
mainfrom
feat/678-rich-tool-output

Conversation

@groksrc

@groksrc groksrc commented Jun 11, 2026

Copy link
Copy Markdown
Member

Closes #678

Summary

Adds human-readable terminal output to four bm tool commands — search-notes, read-note, build-context, recent-activity — with three output modes and a configurable interactive default.

Output modes & precedence

Mode When
JSON --json, or any non-TTY stdout (piped/redirected) — unchanged script compat
Plain --plain — greppable text, no ANSI colors, no box-drawing, no markup (dumb terminals, screen readers, CI logs)
Rich interactive TTY default — Panels/Tables/Trees/rendered Markdown

Precedence: --json > --plain (forces plain even when piped) > non-TTY → JSON > TTY → configured style. --json --plain together is an error. Resolution is centralized in _resolve_output_mode() rather than per-command branching.

New config option

cli_output_style: "rich" | "plain" (default rich, env BASIC_MEMORY_CLI_OUTPUT_STYLE) sets the interactive default for folks whose terminals don't render Rich well. Documented via the config Field description (the repo has no general CLI-settings doc yet — noted on #977).

Renderers

  • search-notes — table with Type/Title/Score/Permalink/Snippet (~200 char matched chunk); panel title shows the query; count falls back to len(results) when the API returns the known total=0-with-results quirk
  • read-note — Rich: header panel + rendered Markdown, frontmatter panel only with --include-frontmatter (the flag makes the API return the literal file as content; the Rich path strips the block from the body so it renders once). Plain: the payload verbatim — note body, or the literal file with the flag — no header, no placeholders, so read-note X --plain --include-frontmatter > note.md round-trips a note byte-faithfully
  • build-context — tree over the real nested ContextResult shape: primary entities, their observations ([category] content), and related results with relation types
  • recent-activity — table of recent items
  • search/build-context/recent-activity have plain-text twins mirroring the same content; plain read-note is the faithful payload (see above)

Hardening (from manual QA + Codex review)

  • Rich markup injection fixed: all user-sourced text (titles, snippets, categories, frontmatter, tree labels, the query) passes through rich.markup.escape — a note titled Spec [draft] v2 renders literally instead of being eaten as a style tag (and [red] can't restyle your terminal). Plain mode deliberately does NOT escape (it isn't markup).
  • String-typed tool results route to the JSON path (also keeps ty's str | dict narrowing clean).

Tests

test_cli_tool_rich_output.py: 40 tests covering Rich + plain + JSON modes per command, the full precedence matrix (incl. --plain while piped, config-driven defaults, --json --plain conflict), bracket-survival in both Rich and plain, frontmatter flag gating, and the total=0 count fallback. Fixtures use the real payload shapes (current_page, nested primary_result/observations/related_results). All existing JSON-path tests unchanged and green.

Flag rename

bm tool read-note --include-frontmatter is renamed to --frontmatter; the old name remains as a deprecated alias (it shipped in 0.22.0). MCP tool parameter include_frontmatter is unchanged.

Manual updates

  • Done now: manual/man3/read-note-3 (team workspace) — CLI synopsis uses --frontmatter, ARGUMENTS notes the deprecated alias.
  • At merge: the manual should gain the output-mode story for the four commands (--json / --plain / Rich default, cli_output_style config, plain read-note's faithful/round-trip behavior) — read-note-3's "json mode only" framing changes once this PR's display modes ship.

Review notes

  • RECENT_ACTIVITY_RESULT fixture documents that the real _extract_recent_rows output has no updated_at key (display falls back to created_at).
  • Plain renderers are intentionally minimal (numbered lists / indented outlines) — formatting opinions welcome.

🤖 Generated with Claude Code

groksrc and others added 3 commits June 11, 2026 01:12
search-notes, read-note, build-context, and recent-activity now display
formatted Rich output (tables, panels, Markdown rendering) when stdout is
an interactive TTY.  When piped or redirected the commands continue to emit
raw JSON exactly as before.  A new --json flag is available on each command
to force JSON output even in a TTY.

Follows the bm status / bm project list precedent: Rich by default for
humans, JSON for machines.

Closes #678

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
…Result shape

_display_build_context was reading top-level item.get("title")/item.get("type")
on each results[i], but the real GraphContext.model_dump() shape wraps every item
as a ContextResult with primary_result + related_results nested inside.  This
made every related note render as an empty tree node.

Fix:
- Rewrite _display_build_context to iterate context_items, build a primary-result
  node from item["primary_result"], then add each item["related_results"] entry as
  a child with relation_type/type/title rendered.
- Update _display_search_results to use the real SearchResponse key "current_page"
  (not "page"), pass query from the CLI argument, add Score and Snippet columns
  (score + matched_chunk/content truncated to 200 chars) as the issue requested.
- Update test fixtures in test_cli_tool_rich_output.py to match the real payload
  shapes (nested ContextResult, current_page, score/matched_chunk fields, no
  updated_at in recent-activity).
- Fix test_build_context_json_flag_overrides_tty assertion to navigate the nested
  primary_result/related_results shape.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
MCP tools return str | dict depending on output_format; the Rich display
helpers require the dict (or list) shape. String payloads keep main's
behavior of printing through the JSON path.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
@groksrc groksrc marked this pull request as ready for review June 11, 2026 07:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 457cd0ed3b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +117 to +119
title = result.get("title", "")
permalink = result.get("permalink", "")
content = result.get("content", "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve frontmatter requested by the flag

When bm tool read-note ... --include-frontmatter runs in an interactive terminal, this new Rich path renders only title, permalink, and content, so the explicitly requested frontmatter disappears unless the user also knows to add --json. That regresses the documented --include-frontmatter behavior for the default TTY path; render result["frontmatter"] when present or keep JSON output for that flag.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e079eca.

Verified: _display_read_note was reading title, permalink, and content but never accessing result["frontmatter"], so --include-frontmatter data was silently dropped in the Rich path.

Fix: when frontmatter is non-empty, render a dim key/value panel (using a borderless Table inside a Panel) above the note content. Added test_read_note_rich_include_frontmatter asserting the frontmatter panel header and key/value (tags/test) appear in output when --include-frontmatter is passed.

Comment on lines +168 to +169
# --- Related items as children ---
related: list[dict[str, Any]] = list(context_result.get("related_results", []))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include observations in context output

For build-context responses where an entity has observations, the new TTY default only adds related_results under each primary item and never renders context_result["observations"]. Since the previous JSON default exposed those observation summaries, users running the command interactively now lose core context facts whenever they do not pass --json.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e079eca.

Verified: _display_build_context iterated related_results under each primary_result node but never accessed context_result["observations"], so the ObservationSummary facts exposed in JSON output were completely absent in the Rich path.

Fix: after rendering the primary node, iterate observations (real ContextResult field: list of ObservationSummary with category + content) and add each as a dim [category] content leaf, truncating at 120 chars. Updated the BUILD_CONTEXT_RESULT fixture to include a real ObservationSummary shape (type/category/content/permalink/file_path/created_at). Also updated the subtitle to show N observations. Added test_build_context_rich_renders_observations asserting the observation category and content text appear in output.

groksrc and others added 3 commits June 11, 2026 09:37
- `_display_read_note`: render frontmatter key/value panel when present so
  `--include-frontmatter` is not silently dropped in the Rich path
- `_display_build_context`: render ContextResult.observations under each
  primary node (category + truncated content) so interactive users see the
  same core facts as `--json` output; update subtitle to include count
- Update BUILD_CONTEXT_RESULT fixture with real ObservationSummary shape
  (type/category/content/permalink/file_path/created_at)
- Add test_read_note_rich_include_frontmatter asserting frontmatter keys appear
- Add test_build_context_rich_renders_observations asserting category/content visible

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
…er flag gate

Bug 1 — Rich markup injection: user-sourced titles, permalinks, snippets,
observation categories/content, and frontmatter keys/values were interpolated
directly into Rich markup strings, causing bracketed text (e.g. "[draft]",
"[fact]") to be silently swallowed or restyled.  Apply markup_escape() at
every injection point in _display_search_results, _display_read_note,
_display_build_context, and _display_recent_activity.  Observation labels use
markup_escape on the full "[category] content" fragment so the literal brackets
around the category are also escaped.

Bug 2 — frontmatter panel ignores flag: _display_read_note rendered the
frontmatter panel whenever result["frontmatter"] was non-empty, but the JSON
payload always carries that key regardless of --include-frontmatter.  Thread
the boolean flag as a keyword argument and gate the panel on both the flag and
non-empty content.

Bug 3 — "0 result(s)" subtitle: the search API returns total=0 even when
results is non-empty.  result.get("total", len(results)) never triggered its
default because the key exists; fall back to len(results) when total is falsy
but results is non-empty, keeping page-count math consistent.

Tests: add cases for bracketed title surviving search output, "[fact]" category
surviving build-context tree, read-note without --include-frontmatter asserting
no frontmatter panel, and search with total=0 asserting correct subtitle count.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
The four interactive bm tool commands (search-notes, read-note,
build-context, recent-activity) now support three output modes instead of
two:

- JSON  — raw machine-readable output (--json, or automatically when piped).
- Rich  — colored Panel/Table/Tree/Markdown (the default TTY experience).
- Plain — undecorated, greppable text with no ANSI colors, box-drawing, or
          markup (--plain, forced even when piped).

Precedence, highest first: --json > --plain > non-TTY (JSON) > TTY (config
style).  Passing both --json and --plain is a typer error with a non-zero
exit.  Each TTY default is governed by the new BasicMemoryConfig field
cli_output_style ("rich"/"plain", default "rich", env
BASIC_MEMORY_CLI_OUTPUT_STYLE), mirroring the existing CLI-behavior config
conventions; its Field description documents the setting since the repo has
no general CLI-settings reference doc.

Plain renderers deliberately do NOT apply rich.markup.escape — escaping is
only correct on the Rich path and would corrupt literal brackets — so
[draft]/[fact] survive verbatim.  The plain search path keeps the total=0
fallback so the corrected result count matches the Rich path.  String tool
results still route to JSON to preserve ty narrowing.

Module docstring and each command's help text now document the three modes
and the config default.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
@groksrc

groksrc commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

--plain output mode + cli_output_style config default

Extends the rich-tool-output work with a third output mode and a configurable interactive default for the four formatted bm tool commands (search-notes, read-note, build-context, recent-activity).

Three output modes

  • JSON — raw machine-readable output. Used with --json, or automatically when stdout is piped/redirected (unchanged script compatibility).
  • Rich — colored Panel/Table/Tree/Markdown. The out-of-the-box TTY experience.
  • Plain — undecorated, greppable text: no ANSI colors, no box-drawing, no markup. Forced with --plain even when piped.
    • search → numbered results with title/score/permalink + indented snippet
    • read-note → title [permalink] header, optional key: value frontmatter (only with --include-frontmatter), then the raw markdown body
    • build-context → ASCII-indented outline (primary, then 2-space-indented observations and related items with relation types)
    • recent-activity → - title (type) permalink updated lines

Plain renderers intentionally do not apply rich.markup.escape (that's only correct on the Rich path and would corrupt literal brackets), so [draft]/[fact] survive verbatim.

Precedence (highest first)

  1. --json — wins over everything
  2. --plain — forces plain, even when piped
  3. non-TTY stdout → JSON (script compatibility)
  4. TTY → config cli_output_style (rich by default)

Passing both --json and --plain is a typer error with a clear message and non-zero exit.

New config option

BasicMemoryConfig.cli_output_style: Literal["rich", "plain"] = "rich" (env BASIC_MEMORY_CLI_OUTPUT_STYLE) sets the interactive TTY default. It follows the existing CLI-behavior field conventions; since the repo has no general CLI-settings reference doc, the Field description documents the setting (same as write_note_overwrite_default).

Tests / gates

  • Per-command plain renderers (incl. literal [draft]/[fact] survival and the total=0 count fallback in plain mode).
  • Precedence matrix: --json alone → JSON; --plain forces plain when piped; non-TTY default still JSON; TTY+config rich → Rich; TTY+config plain → plain; --json --plain together → non-zero error.
  • uv run pytest tests/cli/test_cli_tool_rich_output.py tests/cli/test_cli_tools.py green (42 passed); JSON-output/exit/config suites green; ruff check + format --check clean; uv run ty check src tests test-int passes.

🤖 Generated with Claude Code

groksrc and others added 5 commits June 12, 2026 10:56
The API content field keeps the blank line left by frontmatter stripping;
plain print() rendered it as a double gap under the header. JSON mode stays
byte-faithful.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
With the flag the API returns the literal file as content, so both display
paths printed the frontmatter twice (synthesized block/panel + the block
inside content). Plain now prints the file verbatim; Rich keeps the panel
and strips the block from the Markdown body.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
…de-frontmatter

The file's own frontmatter carries title/permalink, so the header line
duplicated it. Plain without the flag keeps the header.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
Drop the synthesized title/permalink header and the (no content)
placeholder: plain mode is the content verbatim (note body, or the literal
file with --include-frontmatter). Decoration belongs to the Rich path.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
Keep --include-frontmatter as a deprecated alias for back-compat (the old
name shipped in 0.22.0). The MCP tool parameter include_frontmatter is
unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
@groksrc

groksrc commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

This is good to go, but needs to ship in v0.next

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

On Hold Don't review or merge. Work is pending

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI: Human-readable output for bm tool commands (demo-ready formatting)

1 participant