Skip to content

Commit 1e3782d

Browse files
authored
Merge pull request #1 from mariuspruvot/reorganize-cli
Reorganize cli
2 parents 2167942 + b402c03 commit 1e3782d

File tree

20 files changed

+767
-121
lines changed

20 files changed

+767
-121
lines changed

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ uv pip install -e ".[dev]"
6666
# Quick record - press Enter to stop
6767
shh
6868

69-
# Record for specific duration
70-
shh --duration 60
69+
# Minimal output (just result + "Done")
70+
shh --quiet
71+
shh -q
7172
```
7273

7374
### Formatting Styles
@@ -99,6 +100,25 @@ shh config set default_translation_language English
99100
shh
100101
```
101102

103+
### Output Modes
104+
105+
```bash
106+
# Rich UI (default) - colors, panels, live progress
107+
shh
108+
109+
# Quiet mode - minimal output for scripting
110+
shh --quiet
111+
112+
# Set quiet mode as default
113+
shh config set quiet_mode true
114+
115+
# Override quiet mode with verbose flag
116+
shh --verbose
117+
118+
# Combine with other options
119+
shh --quiet --style casual --translate English
120+
```
121+
102122
### Configuration
103123

104124
```bash

shh/cli/app.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,30 @@ def default_command(
4646
help="Translate to language (e.g., English, French)",
4747
),
4848
] = None,
49+
quiet: Annotated[
50+
bool,
51+
typer.Option(
52+
"--quiet",
53+
"-q",
54+
help="Minimal output (overrides config default)",
55+
),
56+
] = False,
57+
verbose: Annotated[
58+
bool,
59+
typer.Option(
60+
"--verbose",
61+
"-v",
62+
help="Rich UI output (overrides config default)",
63+
),
64+
] = False,
4965
) -> None:
5066
"""Record audio and transcribe. Press Enter to stop."""
5167
# If a subcommand was invoked, don't run the default
5268
if ctx.invoked_subcommand is not None:
5369
return
5470

5571
# Run the async record command
56-
asyncio.run(record_command(style=style, translate=translate))
72+
asyncio.run(record_command(style=style, translate=translate, quiet=quiet, verbose=verbose))
5773

5874

5975
def main() -> None:

shh/cli/commands/config.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,15 @@
1313
console = Console()
1414

1515
# Create a sub-app for config commands
16-
config_app = typer.Typer(
17-
help="Manage configuration (wizard, show, get, set, edit, reset)"
18-
)
16+
config_app = typer.Typer(help="Manage configuration (wizard, show, get, set, edit, reset)")
1917

2018

2119
# Valid settings keys for validation
2220
VALID_KEYS = {
2321
"default_style": list(TranscriptionStyle),
2422
"default_translation_language": None, # Freeform text, no validation
2523
"show_progress": [True, False],
24+
"quiet_mode": [True, False],
2625
"whisper_model": list(WhisperModel),
2726
}
2827

@@ -55,6 +54,7 @@ def config_show() -> None:
5554
settings.default_translation_language or "[dim]None[/dim]",
5655
)
5756
table.add_row("show_progress", str(settings.show_progress))
57+
table.add_row("quiet_mode", str(settings.quiet_mode))
5858
table.add_row("whisper_model", str(settings.whisper_model))
5959
table.add_row("default_output", ", ".join(settings.default_output))
6060

@@ -131,6 +131,11 @@ def config_set(key: str, value: str) -> None:
131131
console.print("[red]Error: show_progress must be 'true' or 'false'[/red]")
132132
raise typer.Exit(code=1)
133133
typed_value = value.lower() == "true"
134+
elif key == "quiet_mode":
135+
if value.lower() not in ("true", "false"):
136+
console.print("[red]Error: quiet_mode must be 'true' or 'false'[/red]")
137+
raise typer.Exit(code=1)
138+
typed_value = value.lower() == "true"
134139
elif key == "whisper_model":
135140
try:
136141
typed_value = WhisperModel(value)

shh/cli/commands/record.py

Lines changed: 76 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,154 +1,125 @@
11
"""Recording command for the shh CLI."""
22

3-
import asyncio
4-
import contextlib
53
import logging
64
import sys
75

8-
import pyperclip # type: ignore[import-untyped]
9-
from rich.console import Console
10-
from rich.live import Live
11-
from rich.panel import Panel
12-
from rich.text import Text
13-
14-
from shh.adapters.audio.processor import save_audio_to_wav
15-
from shh.adapters.audio.recorder import AudioRecorder
16-
from shh.adapters.llm.formatter import format_transcription
17-
from shh.adapters.whisper.client import transcribe_audio
6+
from shh.cli.ui import QuietUI, RichUI, UIOutput
7+
from shh.cli.ui.base import RecordingProgress, TranscriptionResult
188
from shh.config.settings import Settings
9+
from shh.core.models import RecordingOptions
1910
from shh.core.styles import TranscriptionStyle
11+
from shh.services.recording import RecordingService
2012

2113
# Suppress HTTP request logs
2214
logging.getLogger("httpx").setLevel(logging.WARNING)
2315
logging.getLogger("openai").setLevel(logging.WARNING)
2416

25-
console = Console()
26-
2717

28-
async def wait_for_enter() -> None:
29-
"""Wait for user to press Enter (runs in thread pool)."""
30-
loop = asyncio.get_running_loop()
31-
await loop.run_in_executor(None, sys.stdin.readline)
18+
def _get_ui(quiet: bool, verbose: bool, settings: Settings) -> UIOutput:
19+
"""
20+
Determine which UI to use based on flags and config.
21+
22+
Priority: verbose flag > quiet flag > config setting
23+
24+
Args:
25+
quiet: Force quiet mode
26+
verbose: Force verbose mode
27+
settings: User settings
28+
29+
Returns:
30+
UIOutput instance (RichUI or QuietUI)
31+
"""
32+
if verbose:
33+
return RichUI()
34+
if quiet:
35+
return QuietUI()
36+
return QuietUI() if settings.quiet_mode else RichUI()
3237

3338

3439
async def record_command(
3540
style: TranscriptionStyle | None = None,
3641
translate: str | None = None,
42+
quiet: bool = False,
43+
verbose: bool = False,
3744
) -> None:
3845
"""
3946
Record audio, transcribe, and optionally format/translate.
4047
4148
Args:
4249
style: Formatting style to apply (overrides config default)
4350
translate: Target language for translation
51+
quiet: Force minimal output (overrides config)
52+
verbose: Force rich UI (overrides config)
4453
"""
4554
# Load settings
4655
settings = Settings.load_from_file()
4756
if not settings or not settings.openai_api_key:
48-
console.print("[red]Error: No API key found.[/red]")
49-
console.print("[dim]Run 'shh setup' to configure your OpenAI API key.[/dim]")
57+
# For error message, default to Rich UI unless quiet explicitly set
58+
ui: UIOutput = QuietUI() if quiet else RichUI()
59+
ui.show_error(
60+
"No API key found.",
61+
"Run 'shh setup' to configure your OpenAI API key.",
62+
)
5063
sys.exit(1)
5164

5265
# Use provided options or fall back to config defaults
5366
formatting_style = style if style is not None else settings.default_style
5467
target_language = translate if translate is not None else settings.default_translation_language
68+
ui = _get_ui(quiet, verbose, settings)
5569

56-
# Step 1: Recording
57-
console.print()
70+
# Create service
71+
service = RecordingService(settings)
72+
options = RecordingOptions(
73+
style=formatting_style,
74+
translate=target_language,
75+
show_progress=settings.show_progress,
76+
)
5877

5978
try:
60-
async with AudioRecorder() as recorder:
61-
# Start waiting for Enter in background
62-
enter_task = asyncio.create_task(wait_for_enter())
63-
64-
# Show live progress
65-
with Live(auto_refresh=False, console=console) as live:
66-
while not enter_task.done() and not recorder.is_max_duration_reached():
67-
elapsed = recorder.elapsed_time()
68-
max_duration = recorder._max_duration
69-
70-
# Create progress text
71-
progress = Text()
72-
progress.append("Recording... ", style="bold green")
73-
progress.append(f"{elapsed:.1f}s ", style="cyan")
74-
progress.append(f"/ {max_duration:.0f}s ", style="dim")
75-
progress.append("[Press Enter to stop]", style="dim")
76-
77-
live.update(progress)
78-
live.refresh()
79-
await asyncio.sleep(0.1)
80-
81-
# Check if max duration reached
82-
if recorder.is_max_duration_reached():
83-
console.print(
84-
"\n[yellow]Maximum recording duration reached (5 minutes)[/yellow]"
85-
)
86-
87-
# Cancel Enter task if we hit max duration
88-
if not enter_task.done():
89-
enter_task.cancel()
90-
with contextlib.suppress(asyncio.CancelledError):
91-
await enter_task
92-
93-
# Get recorded audio
94-
audio_data = recorder.get_audio()
79+
# Recording phase
80+
ui.show_recording_start()
9581

96-
except KeyboardInterrupt:
97-
console.print("\n[yellow]Recording cancelled.[/yellow]")
98-
sys.exit(130) # Standard exit code for Ctrl+C
82+
def progress_callback(elapsed: float, max_duration: float) -> None:
83+
ui.show_recording_progress(RecordingProgress(elapsed, max_duration))
9984

100-
# Check if we got any audio
101-
if len(audio_data) == 0:
102-
console.print("[yellow]No audio recorded.[/yellow]")
103-
sys.exit(1)
85+
# Always pass progress callback - UI decides how to display it
86+
audio_data = await service.record_audio(on_progress=progress_callback)
10487

105-
# Step 2: Save to WAV
106-
console.print("\n[cyan]Saving audio...[/cyan]")
107-
audio_file_path = save_audio_to_wav(audio_data)
88+
ui.show_recording_stopped()
10889

109-
try:
110-
# Step 3: Transcribe
111-
console.print("[cyan]Transcribing with Whisper...[/cyan]")
112-
transcription = await transcribe_audio(
113-
audio_file_path=audio_file_path,
114-
api_key=settings.openai_api_key,
115-
)
90+
# Check if we got any audio
91+
if len(audio_data) == 0:
92+
ui.show_warning("No audio recorded.")
93+
sys.exit(1)
94+
95+
# Processing phases
96+
ui.show_processing_step("Saving audio...")
97+
ui.show_processing_step("Transcribing with Whisper...")
11698

117-
# Step 4: Format/Translate (if requested)
11899
if formatting_style != TranscriptionStyle.NEUTRAL or target_language:
119100
if target_language:
120-
console.print(f"[cyan]Formatting and translating to {target_language}...[/cyan]")
101+
ui.show_processing_step(f"Formatting and translating to {target_language}...")
121102
else:
122-
console.print(f"[cyan]Formatting ({formatting_style})...[/cyan]")
123-
124-
formatted = await format_transcription(
125-
transcription,
126-
style=formatting_style,
127-
api_key=settings.openai_api_key,
128-
target_language=target_language,
103+
ui.show_processing_step(f"Formatting ({formatting_style})...")
104+
105+
# Transcribe and format
106+
result = await service.transcribe_and_format(audio_data, options)
107+
108+
# Display result
109+
ui.show_result(
110+
TranscriptionResult(
111+
text=result.text,
112+
copied_to_clipboard=result.copied_to_clipboard,
113+
style=result.style.value,
114+
translated_to=result.translated_to,
129115
)
130-
final_text = formatted.text
131-
else:
132-
final_text = transcription
133-
134-
# Step 5: Copy to clipboard
135-
clipboard_success = True
136-
try:
137-
pyperclip.copy(final_text)
138-
except Exception as e:
139-
clipboard_success = False
140-
console.print(f"[yellow]Warning: Could not copy to clipboard: {e}[/yellow]")
141-
142-
# Step 6: Display result
143-
console.print()
144-
result_panel = Panel(
145-
final_text,
146-
title="Transcription" + (" (copied to clipboard)" if clipboard_success else ""),
147-
border_style="green" if clipboard_success else "yellow",
148116
)
149-
console.print(result_panel)
150-
console.print()
151117

118+
except KeyboardInterrupt:
119+
ui.show_warning("Recording cancelled.")
120+
sys.exit(130) # Standard exit code for Ctrl+C
121+
except ValueError as e:
122+
ui.show_error(str(e))
123+
sys.exit(1)
152124
finally:
153-
# Cleanup temp file
154-
audio_file_path.unlink(missing_ok=True)
125+
ui.cleanup()

shh/cli/ui/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""UI layer for CLI output formatting."""
2+
3+
from shh.cli.ui.base import UIOutput
4+
from shh.cli.ui.quiet_ui import QuietUI
5+
from shh.cli.ui.rich_ui import RichUI
6+
7+
__all__ = ["UIOutput", "RichUI", "QuietUI"]

0 commit comments

Comments
 (0)