Skip to content

Commit 363125b

Browse files
elifarleyclaude
andcommitted
Add elegant headers and totals to ls subcommand
- Add --skip-header and --skip-totals CLI options for user control - Headers display column names (Words, Size, Modified, Path) by default - Totals footer shows aggregated counts (only when ≥2 data lines) - Adaptive formatting: headers adapt to --date none mode - Shared utilities module for consistent formatting across renderers - Works for both flat and tree output modes - Comprehensive test suite with 14 new test cases - Default behavior: headers always shown, totals only when meaningful Examples: Headers with date: "Words Size Modified Path" Headers without date: "Words Size Path" Totals: "------- ------------ ------------------- ---- TOTAL" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 15551fd commit 363125b

File tree

6 files changed

+481
-8
lines changed

6 files changed

+481
-8
lines changed

src/cedarmapper/cli/main.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ def run_cli(argv: list[str] | None = None) -> tuple[int, str]:
3030
date_mode = "seconds" # seconds | day | none
3131
numbered_indent = False
3232
sort_spec = None
33+
skip_header = False
34+
skip_totals = False
3335

3436
it = iter(argv)
3537
for token in it:
@@ -58,6 +60,10 @@ def run_cli(argv: list[str] | None = None) -> tuple[int, str]:
5860
sort_spec = next(it)
5961
except StopIteration:
6062
return 2, "Error: --sort expects a value"
63+
elif token == "--skip-header":
64+
skip_header = True
65+
elif token == "--skip-totals":
66+
skip_totals = True
6167
elif token.startswith("-"):
6268
# unknown flag
6369
return 2, f"Error: unknown option {token}"
@@ -80,9 +86,18 @@ def run_cli(argv: list[str] | None = None) -> tuple[int, str]:
8086
date_mode=date_mode,
8187
numbered_indent=numbered_indent,
8288
sort_spec=sort_spec,
89+
skip_header=skip_header,
90+
skip_totals=skip_totals,
8391
)
8492
else:
85-
out = render_flat(dirinfo, max_depth=max_depth, date_mode=date_mode, sort_spec=sort_spec)
93+
out = render_flat(
94+
dirinfo,
95+
max_depth=max_depth,
96+
date_mode=date_mode,
97+
sort_spec=sort_spec,
98+
skip_header=skip_header,
99+
skip_totals=skip_totals,
100+
)
86101

87102
return 0, out
88103

@@ -131,6 +146,18 @@ def cli() -> None:
131146
default=None,
132147
help="Sort specification: a series of keys optionally prefixed with '-' for reverse. Keys: w=word count, s=size, d=date, i=depth, n=name, p=path. Examples: 'w', '-s', 'wi', 'i-sd'.",
133148
)
149+
@click.option(
150+
"--skip-header",
151+
is_flag=True,
152+
default=False,
153+
help="Skip column header display.",
154+
)
155+
@click.option(
156+
"--skip-totals",
157+
is_flag=True,
158+
default=False,
159+
help="Skip totals footer display.",
160+
)
134161
def ls(
135162
path: str,
136163
max_depth: int,
@@ -140,6 +167,8 @@ def ls(
140167
date_mode: str,
141168
numbered_indent: bool,
142169
sort_spec: str | None,
170+
skip_header: bool,
171+
skip_totals: bool,
143172
) -> None:
144173
"""List repository contents with analysis for LLM consumption."""
145174
argv = [path]
@@ -157,6 +186,10 @@ def ls(
157186
argv.append("--numbered-indent")
158187
if sort_spec:
159188
argv.extend(["--sort", sort_spec])
189+
if skip_header:
190+
argv.append("--skip-header")
191+
if skip_totals:
192+
argv.append("--skip-totals")
160193

161194
code, out = run_cli(argv)
162195

src/cedarmapper/render/flat.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
from cedarmapper.core.model import DirInfo, FileInfo
99
from cedarmapper.render.sort import make_comparator, parse_sort_spec
10+
from cedarmapper.render.utils import (
11+
format_header_row,
12+
format_separator_row,
13+
format_totals_row,
14+
get_column_specs,
15+
)
1016

1117

1218
def _fmt_count(c: int | None) -> str:
@@ -49,14 +55,22 @@ def __init__(
4955

5056

5157
def render_flat(
52-
root: DirInfo, max_depth: int = 0, *, date_mode: str = "seconds", sort_spec: str | None = None
58+
root: DirInfo,
59+
max_depth: int = 0,
60+
*,
61+
date_mode: str = "seconds",
62+
sort_spec: str | None = None,
63+
skip_header: bool = False,
64+
skip_totals: bool = False,
5365
) -> str:
5466
"""
5567
Render all nodes (dirs and files) that are at display depth <= max_depth.
5668
Flat output prints the path (relative to root) without indentation.
5769
5870
- date_mode: 'seconds' | 'day' | 'none'
5971
- sort_spec: string describing sorting like 'wi-sd'
72+
- skip_header: if True, don't show column headers
73+
- skip_totals: if True, don't show totals footer
6074
"""
6175
rows: list[_DisplayRow] = []
6276

@@ -112,6 +126,13 @@ def _render_node(node: DirInfo | FileInfo, depth: int, parent_path: str) -> None
112126

113127
# Build lines
114128
lines: list[str] = []
129+
130+
# Add header if not skipped
131+
if not skip_header:
132+
column_specs = get_column_specs(date_mode)
133+
lines.append(format_header_row(column_specs))
134+
135+
# Add data rows
115136
for r in rows:
116137
date_str = _fmt_mtime(r.mtime, date_mode)
117138
if date_mode == "none":
@@ -121,4 +142,10 @@ def _render_node(node: DirInfo | FileInfo, depth: int, parent_path: str) -> None
121142
f"{_fmt_count(r.word_count):>7} {r.size_bytes:>12} {date_str:>19} {r.path}"
122143
)
123144

145+
# Add totals footer if not skipped and we have multiple data rows
146+
if not skip_totals and len(rows) >= 2:
147+
column_specs = get_column_specs(date_mode)
148+
lines.append(format_separator_row(column_specs))
149+
lines.append(format_totals_row(column_specs, root))
150+
124151
return "\n".join(lines)

src/cedarmapper/render/tree.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
from cedarmapper.core.model import DirInfo, FileInfo
99
from cedarmapper.render.sort import make_comparator, parse_sort_spec
10+
from cedarmapper.render.utils import (
11+
format_header_row,
12+
format_separator_row,
13+
format_totals_row,
14+
get_column_specs,
15+
)
1016

1117

1218
def _fmt_count(c: int | None) -> str:
@@ -34,15 +40,19 @@ def render_tree(
3440
date_mode: str = "seconds",
3541
numbered_indent: bool = False,
3642
sort_spec: str | None = None,
43+
skip_header: bool = False,
44+
skip_totals: bool = False,
3745
) -> str:
3846
"""
3947
Render tree structure with word count, bytes, mtime (per date_mode), and path.
4048
4149
- date_mode: 'seconds' | 'day' | 'none'
4250
- numbered_indent: if True, prefix each entry with the node depth number instead of indent chars.
4351
- sort_spec: sort string passed to parse_sort_spec
52+
- skip_header: if True, don't show column headers
53+
- skip_totals: if True, don't show totals footer
4454
"""
45-
lines: list[str] = []
55+
data_lines: list[str] = []
4656

4757
# Characters used for branches (similar to `tree` command)
4858
VERTICAL = "│"
@@ -82,23 +92,23 @@ def _render_node(
8292
date_str = _fmt_mtime(node.mtime, date_mode)
8393
display_name = node.path.name
8494
if date_mode == "none":
85-
lines.append(
95+
data_lines.append(
8696
f"{_fmt_count(node.word_count):>7} {node.size_bytes:>12} {prefix}{display_name}"
8797
)
8898
else:
89-
lines.append(
99+
data_lines.append(
90100
f"{_fmt_count(node.word_count):>7} {node.size_bytes:>12} {date_str:>19} {prefix}{display_name}"
91101
)
92102
else:
93103
# DirInfo
94104
date_str = _fmt_mtime(node.mtime, date_mode)
95105
display_name = f"{node.path.name}/"
96106
if date_mode == "none":
97-
lines.append(
107+
data_lines.append(
98108
f"{str(node.word_count):>7} {node.size_bytes:>12} {prefix}{display_name}"
99109
)
100110
else:
101-
lines.append(
111+
data_lines.append(
102112
f"{str(node.word_count):>7} {node.size_bytes:>12} {date_str:>19} {prefix}{display_name}"
103113
)
104114

@@ -157,4 +167,22 @@ def __init__(self, obj, depth_value, parent_path):
157167

158168
# root: start with empty parent_display_path
159169
_render_node(root, 0, [], "")
170+
171+
# Build final output with headers and totals
172+
lines: list[str] = []
173+
174+
# Add header if not skipped
175+
if not skip_header:
176+
column_specs = get_column_specs(date_mode)
177+
lines.append(format_header_row(column_specs))
178+
179+
# Add data rows
180+
lines.extend(data_lines)
181+
182+
# Add totals footer if not skipped and we have multiple data rows
183+
if not skip_totals and len(data_lines) >= 2:
184+
column_specs = get_column_specs(date_mode)
185+
lines.append(format_separator_row(column_specs))
186+
lines.append(format_totals_row(column_specs, root))
187+
160188
return "\n".join(lines)

src/cedarmapper/render/utils.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Shared utilities for renderers: formatting, headers, and totals."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
from typing import NamedTuple
7+
8+
from cedarmapper.core.model import DirInfo
9+
10+
11+
class ColumnSpec(NamedTuple):
12+
"""Column specification for headers/totals."""
13+
name: str
14+
width: int
15+
formatter: callable
16+
17+
18+
def get_column_specs(date_mode: str) -> list[ColumnSpec]:
19+
"""Get column specifications based on date mode."""
20+
specs = [
21+
ColumnSpec("Words", 7, _fmt_count),
22+
ColumnSpec("Size", 12, lambda x: str(x)),
23+
]
24+
if date_mode != "none":
25+
specs.append(ColumnSpec("Modified", 19, lambda dt: _fmt_mtime(dt, date_mode)))
26+
return specs
27+
28+
29+
def format_header_row(column_specs: list[ColumnSpec], show_path: bool = True) -> str:
30+
"""Format header row with column names."""
31+
parts = [f"{spec.name:>{spec.width}}" for spec in column_specs]
32+
if show_path:
33+
parts.append("Path")
34+
return " ".join(parts)
35+
36+
37+
def format_separator_row(column_specs: list[ColumnSpec], show_path: bool = True) -> str:
38+
"""Format separator row with dashes matching column widths."""
39+
parts = ["-" * spec.width for spec in column_specs]
40+
if show_path:
41+
parts.append("-" * 20) # Reasonable width for Path column
42+
return " ".join(parts)
43+
44+
45+
def format_totals_row(column_specs: list[ColumnSpec], root: DirInfo, show_path: bool = True) -> str:
46+
"""Format totals row using same column alignment."""
47+
parts = []
48+
for spec in column_specs:
49+
if spec.name == "Words":
50+
value = root.word_count
51+
formatted_value = spec.formatter(value)
52+
elif spec.name == "Size":
53+
value = root.size_bytes
54+
formatted_value = spec.formatter(value)
55+
elif spec.name == "Modified":
56+
value = root.mtime
57+
formatted_value = spec.formatter(value)
58+
else:
59+
formatted_value = ""
60+
parts.append(f"{formatted_value:>{spec.width}}")
61+
62+
if show_path:
63+
parts.append("TOTAL")
64+
return " ".join(parts)
65+
66+
67+
def _fmt_count(c: int | None) -> str:
68+
"""Format word count for display."""
69+
return "-" if c is None else str(c)
70+
71+
72+
def _fmt_mtime(dt: datetime, mode: str) -> str:
73+
"""Format modification time for display."""
74+
if mode == "none":
75+
return ""
76+
if mode == "day":
77+
return dt.date().isoformat()
78+
return dt.replace(microsecond=0).isoformat()

tests/test_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def test_cli_skip_word_count_flag(self):
5151
tmppath = Path(tmpdir)
5252
(tmppath / "test.txt").write_text("hello world")
5353

54-
code, out = run_cli([str(tmppath), "--skip-word-count"])
54+
code, out = run_cli([str(tmppath), "--skip-word-count", "--skip-header"])
5555
assert code == 0
5656
# because we skipped word count, first column should be "-" (or none)
5757
assert "-" in out.splitlines()[0]

0 commit comments

Comments
 (0)