Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Why `3.12`:
- Python 3.12 for development, runtime support `>=3.10,<3.13` + Python standard library, native Aim CLI (external runtime prerequisite for delegated commands), pytest for test automation (001-aim-command-passthrough)
- Python 3.12 for development, runtime support `>=3.10,<3.13` + Python standard library, Aim SDK from the dev dependency group for local development and tests (001-aim-command-passthrough)
- Existing local Aim repositories on disk, including repo roots that contain a `.aim` metadata directory (001-aim-command-passthrough)
- Python 3.12 for development, runtime support `>=3.10,<3.13` + `rich>=13.7`, `textual-image>=0.12.0` (already declared in (003-query-images-terminal-render)
- Existing local Aim repositories (read-only). Image bytes are read (003-query-images-terminal-render)

## Recent Changes
- 001-aim-command-passthrough: Added Python 3.12 for development, runtime support `>=3.10,<3.13` + Python standard library, native Aim CLI (external runtime prerequisite for delegated commands), pytest for test automation
57 changes: 51 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,60 @@ aimx query metrics "metric.name == 'loss'" --repo data --steps 100:500
aimx query metrics "metric.name == 'loss'" --repo data --steps :50 # first 50 steps
aimx query metrics "metric.name == 'loss'" --repo data --steps 100: # from step 100 onwards

# Combine short hash + step range
aimx query metrics "run.hash=='eca37394' and metric.name=='loss'" --repo data --steps 100:300

# Images
aimx query images "images" --repo data
# Epoch range filter (mutually exclusive with --steps)
aimx query metrics "metric.name == 'loss'" --repo data --epochs 1:10
aimx query metrics "metric.name == 'loss'" --repo data --epochs :5

# Density subsampling: first N / last N / every K-th point per series
aimx query metrics "metric.name == 'loss'" --repo data --head 20
aimx query metrics "metric.name == 'loss'" --repo data --tail 20
aimx query metrics "metric.name == 'loss'" --repo data --every 5

# Combine short hash + step range + head
aimx query metrics "run.hash=='eca37394' and metric.name=='loss'" --repo data --steps 100:300 --head 10

# Images — metadata table only (--json / --plain / redirected stdout)
aimx query images "images" --repo data --json
aimx query images "images" --repo data --plain

# Images — filter by epoch range (affects all output modes)
aimx query images "images" --repo data --epochs 10:50 --plain
aimx query images "images" --repo data --epochs :30 --json

# Images — global row subsampling (applied to the sorted result list)
aimx query images "images" --repo data --head 5
aimx query images "images" --repo data --tail 5
aimx query images "images" --repo data --every 3

# Images — inline preview in a modern terminal (iTerm2 / Kitty / WezTerm / Ghostty)
aimx query images "images" --repo data # default: renders up to 6 images inline
aimx query images "images" --repo data --max-images 20 # render more
aimx query images "images" --repo data --max-images 0 # no cap (render all)

# Combine epoch filter + head + TTY cap
aimx query images "images" --repo data --epochs 10:50 --head 10 --max-images 4
```

Output modes: `--json` (nested runs→metrics), `--oneline` / `--plain` (tab-separated),
default (rich table). Additional flags: `--steps start:end`, `--no-color`, `--verbose`.
default (rich table with inline image preview).
Filter/sampling flags (affect all output modes): `--steps start:end | --epochs start:end`
(mutually exclusive), `--head N`, `--tail N`, `--every K`.
Additional flags: `--no-color`, `--verbose`, `--max-images N` (images TTY cap only).

#### Inline image preview

![aimx query images output preview](static/images.png)

When stdout is a TTY and `aimx` detects a graphics-capable terminal, `aimx query images`
renders matched images directly in the terminal. On plain ANSI terminals it falls back
to half-block character art — exit code is always `0`.

Terminal support is provided by [`textual-image`](https://github.com/lnqs/textual-image/tree/main#support-matrix-1).
Confirmed working terminals include: iTerm2, Kitty, Konsole, WezTerm, foot, tmux (Sixel),
xterm (Sixel), Windows Terminal, and VS Code integrated terminal. Warp and GNOME Terminal
are not supported.

To disable inline rendering without changing flags, redirect stdout `aimx query images > out.txt` or use `--plain` / `--json`.

### `aimx trace` — plot or export a metric time series

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[project]
name = "aimx"
version = "0.3.0"
version = "0.3.1"
description = "A safe CLI-first companion for native Aim"
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = [
"numpy>=1.24",
"plotext>=5.3",
"rich>=13.7",
"textual-image>=0.12.0",
]

[project.scripts]
Expand Down
35 changes: 35 additions & 0 deletions specs/003-query-images-terminal-render/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Inline Terminal Image Rendering for `aimx query images`

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-22
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs) — `textual_image` / `rich.Console` 只在 Assumptions 中出现,作为用户在 query 中明确指定的依赖说明;FR 层保持协议/能力描述
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain — 已在 2026-04-22 clarify 会话中全部解决,实现完成后再次确认
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded(仅单帧静态图片;不含视频/GIF)
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- 2026-04-22 clarify 会话解决了 5 个问题:无新开关(FR-008)、默认上限 6 张(FR-007)、保留汇总表格 + 分段打印(FR-005)、依赖警告写 stderr(FR-009)、高度上限为终端行数 1/3(FR-006)。
- `textual_image` 依赖的加入将在 `/speckit.plan` 阶段纳入实现计划。
122 changes: 122 additions & 0 deletions specs/003-query-images-terminal-render/contracts/cli-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# CLI Output Contract: `aimx query images` (003)

**Feature**: `003-query-images-terminal-render`
**Date**: 2026-04-22

This contract defines the observable CLI output of `aimx query images` after
feature 003 is implemented. It supersedes the relevant portions of the 002
contract only for the `images` target; the `metrics` target is unchanged.

---

## 1. Command surface

```
aimx query images <expression> [--repo <path>] [--json] [--oneline | --plain]
[--no-color] [--verbose] [--steps start:end]
[--max-images <N>]
```

Changes vs. feature 002:

- New flag `--max-images <N>` (default: `6`; `0` = unlimited).
- Only affects the default rich TTY path.
- Has **no effect** on `--json` or `--plain` output bytes.

No other new flags are added (clarify Q1).

## 2. Exit codes (unchanged from 002)

| Exit | Meaning |
|------|---------|
| `0` | Query executed successfully (even if 0 matches, even if some images failed to render). |
| `2` | Usage error (bad args, bad `--repo`, bad `--max-images`), or underlying Aim error. |

Image-rendering failures MUST NOT change the exit code. Missing
`textual_image` or PIL MUST NOT change the exit code.

## 3. Output mode matrix

Let `TTY := sys.stdout.isatty()`.

| Mode | stdout bytes | stderr bytes |
|----------------------------------------|---------------------|--------------------------------------|
| `--json` | **identical to 002**| empty (unless query errors) |
| `--plain` / `--oneline` | **identical to 002**| empty (unless query errors) |
| rich + `TTY == False` | **identical to 002**| empty (unless query errors) |
| rich + `TTY == True` | 002 summary table **followed by** §4 block(s) | one-shot dep/decoding warning if any |

"**identical to 002**" is a hard contract (SC-003): byte-for-byte equal to
the pre-003 output for the same inputs. This is enforced by
`tests/contract/test_query_contract.py`.

## 4. Rich TTY output layout

When rendered on a TTY, the stdout stream is the concatenation of:

1. **Summary table** — produced by the existing
`render_image_rich_table(...)`. Unchanged columns: `RUN`, `EXPERIMENT`,
`NAME`, `CONTEXT`. Unchanged header.
2. **Zero or more section blocks**, one per row in
`ImageRenderPlan.rendered_rows`:
```
<blank line>
▌ <hash8> <experiment> <name> <ctx-kv-string>
<blank line>
<image renderable — width ≤ cols, height ≤ rows//3>
```
The header line reuses the existing rich styles (`colors.RUN_HASH`,
`colors.EXPERIMENT`, `colors.METRIC_NAME`, `colors.CONTEXT_VAL`). The
image renderable is produced by `textual_image.renderable.Image`.
3. **Truncation footer** (only when `len(skipped_rows) > 0`):
```
... rendered <K> of <M> images, use --max-images=0 for all
```
Exactly one line, printed after the last section block.

Edge cases:

- `capability.columns < 20` or `capability.rows < 9`
(so `rows//3 < 3`): skip §4 entirely; stdout matches the non-TTY mode
bytes exactly.
- Per-row decoding failure: replace the image renderable with a single
line `[image unavailable: <short reason>]`; the section block otherwise
looks the same.

## 5. `--max-images` semantics

- `--max-images 6` (default): render the first 6 rows; remaining rows
contribute **only** to §4's truncation footer (no section block, no
image bytes).
- `--max-images 0`: unlimited; render every row.
- `--max-images N` with `N < 0` or non-integer: usage error, exit `2`.

Only the default-rich-TTY path consults `--max-images`. It is a hard error
to observe any `--max-images`-driven difference in `--json`, `--plain`, or
non-TTY output.

## 6. Warning contract (stderr)

At most one line may be written to stderr during an otherwise-successful
invocation, in either of the following shapes:

```
aimx: inline image rendering unavailable: <reason>
```

Triggered when `textual_image` / PIL cannot be imported, or when
terminal-capability detection disables image drawing on a TTY (e.g.
unrecognised `$TERM`). De-duplicated within a single process via a
module-level flag so batched invocations in Python never produce more
than one warning.

No stderr output is produced on the non-TTY / `--json` / `--plain` paths.

## 7. Compatibility expectations

- Aim SDK: `>= 3.29` (covered by the existing `dev` dependency group).
- Python: `>= 3.10, < 3.13` (matches `pyproject.toml`).
- `rich >= 13.7`, `textual-image >= 0.12.0` (already declared).
- Terminal targets explicitly validated: iTerm2, Kitty, WezTerm, Ghostty
(all "auto" protocol), plus tmux-over-xterm and plain `xterm`
(fallback half-block path), plus non-TTY stdout (no image bytes at all).
112 changes: 112 additions & 0 deletions specs/003-query-images-terminal-render/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Phase 1 Data Model: Inline Terminal Image Rendering for `aimx query images`

**Feature**: `003-query-images-terminal-render`
**Date**: 2026-04-22

This feature is a rendering-layer extension; it does not introduce new
persistent state, storage schemas, or Aim SDK mutations. The "data model"
below captures the **in-memory value objects** added on the rendering path
and clarifies how they interact with the existing `collect_image_series`
output.

---

## 1. `TerminalCapability`

Captures everything the renderer needs to know about the current terminal.
Resolved once per `aimx query images` invocation, before any image is
decoded.

| Field | Type | Notes |
|----------------|----------------------------------------|-------|
| `is_tty` | `bool` | Equals `sys.stdout.isatty()`. |
| `columns` | `int` | From `shutil.get_terminal_size(fallback=(120, 24))`. |
| `rows` | `int` | Same source as `columns`. |
| `protocol` | `Literal["auto", "fallback_text", "disabled"]` | `"auto"` when `is_tty` and `textual_image` is available; `"fallback_text"` for the half-block path; `"disabled"` when non-TTY or deps missing. |
| `reason` | `str \| None` | When `protocol == "disabled"`, human-readable reason used in the one-shot stderr warning. |

Validation rules:

- `columns >= 20` — below this width the renderer MUST skip image drawing
and revert to metadata-only output (edge case in spec).
- `protocol == "disabled"` iff rendering is suppressed entirely (non-TTY,
`--plain`, `--json`, or missing dependency); in that case
`image_render.render_inline(...)` MUST return an empty string.

State transitions: none — the value is immutable for the lifetime of the
invocation.

## 2. `ImageRow` (extension, not a new type)

The existing `collect_image_series` dict is kept as-is for JSON/plain
renderers. The rendering path reads the same dict, plus an **optional**
lazy accessor:

| Key | Type | Notes |
|------------------|-----------------------------------|-------|
| `run` | `RunMeta` | Unchanged. |
| `name` | `str` | Unchanged. |
| `context` | `dict[str, Any]` | Unchanged. |
| `_image_accessor`| `Callable[[], PIL.Image.Image] \| None` | **New**, private; present only when rendering is possible; invoked lazily inside `image_render.render_inline`. `--json` and `--plain` MUST NOT touch this key. |

Validation rules:

- `_image_accessor` is `None` in non-TTY / `--json` / `--plain` runs so the
existing renderers remain byte-identical.
- Calling the accessor MUST NOT raise across the whole renderer; all
exceptions are caught and converted to `[image unavailable: …]`.

## 3. `ImageRenderPlan`

Pure data passed between "decide what to render" and "actually render".
Makes the cap logic (Q2/Q5) unit-testable.

| Field | Type | Notes |
|-----------------|------------------------------------|-------|
| `capability` | `TerminalCapability` | Borrowed from §1. |
| `rendered_rows` | `list[ImageRow]` | The first `max_images` rows (or all if `max_images == 0`). |
| `skipped_rows` | `list[ImageRow]` | Rows whose metadata will still be printed but no image drawn. |
| `target_width` | `int` | `capability.columns` (or slightly less for margins). |
| `max_height` | `int` | `capability.rows // 3`; lower bound 3 cells. |
| `max_images` | `int` | Effective cap; `0` means unlimited. |

Derived rules:

- If `capability.protocol == "disabled"`, both `rendered_rows` and
`skipped_rows` MUST be empty and the renderer MUST short-circuit.
- `len(rendered_rows) + len(skipped_rows) == len(all_rows)`.
- `max_height >= 3` — any smaller and we skip image drawing entirely and
move the row from `rendered_rows` to `skipped_rows`.

## 4. Invocation flag addition

| Field on `QueryInvocation` | Type | Default | Notes |
|----------------------------|------|---------|-------|
| `max_images` | `int` | `6` | `0` → unlimited; negative values rejected at parse time. |

Parsing rules (enforced in `parse_query_invocation`):

- Token: `--max-images <N>`.
- `<N>` MUST parse as a non-negative integer; otherwise raise
`ValueError("Invalid --max-images value: <token>")`.
- No other new flags are introduced (per clarify Q1).

## 5. Relationships

```
QueryInvocation ──────────────► ImageRenderPlan ◄────── TerminalCapability
│ │
│ max_images, target=images │ rendered_rows/skipped_rows
▼ ▼
collect_image_series ────► list[ImageRow] (with _image_accessor on TTY)
render_image_rich_table ──► summary (stdout)
image_render.render_inline ──► inline images (stdout)
```

No persisted state. No cross-invocation cache. No shared mutable singleton
aside from the one-shot stderr warning flag guarded by a module-level
`bool`.
Loading
Loading