msapling-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
msapling_cli/tui.py ADDED
@@ -0,0 +1,475 @@
1
+ """MSapling TUI — Terminal UI layer matching Claude Code / Codex / Gemini CLI polish.
2
+
3
+ Provides:
4
+ - Status bar at bottom (model, cost, tokens, mode, project)
5
+ - Animated working indicator with shimmer effect
6
+ - Inline colored diffs for file edits
7
+ - Styled tool execution blocks with spinners
8
+ - Themed prompt and response rendering
9
+ - Width-responsive layout
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import os
15
+ import time
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from rich.console import Console, Group
19
+ from rich.columns import Columns
20
+ from rich.live import Live
21
+ from rich.markdown import Markdown
22
+ from rich.markup import escape
23
+ from rich.panel import Panel
24
+ from rich.rule import Rule
25
+ from rich.style import Style
26
+ from rich.syntax import Syntax
27
+ from rich.table import Table
28
+ from rich.text import Text
29
+ from rich.theme import Theme
30
+
31
+ # ─── MSapling Color Theme ─────────────────────────────────────────────
32
+
33
+ MSAPLING_THEME = Theme({
34
+ "ms.accent": "bold #3b82f6", # Blue accent
35
+ "ms.accent2": "#22c55e", # Green
36
+ "ms.warn": "#f59e0b", # Amber
37
+ "ms.error": "bold #ef4444", # Red
38
+ "ms.dim": "dim #64748b", # Slate gray
39
+ "ms.muted": "#94a3b8", # Light slate
40
+ "ms.tool": "bold #a78bfa", # Purple for tool names
41
+ "ms.file": "bold #38bdf8", # Sky blue for file paths
42
+ "ms.cost": "#facc15", # Yellow for costs
43
+ "ms.prompt": "bold #22c55e", # Green prompt
44
+ "ms.user": "bold #e2e8f0", # Bright user text
45
+ "ms.assistant": "#e2e8f0", # Assistant text
46
+ "ms.diff.add": "#22c55e", # Diff added
47
+ "ms.diff.del": "#ef4444", # Diff removed
48
+ "ms.diff.hunk": "bold #3b82f6", # Diff hunk header
49
+ "ms.status": "#94a3b8 on #1e293b", # Status bar
50
+ "ms.status.key": "bold #3b82f6 on #1e293b",
51
+ })
52
+
53
+ console = Console(theme=MSAPLING_THEME)
54
+
55
+ # ─── Working Indicator ─────────────────────────────────────────────────
56
+
57
+ _SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
58
+ _SHIMMER_CHARS = "MSapling"
59
+
60
+
61
+ def shimmer_text(label: str = "Working", elapsed: float = 0.0) -> Text:
62
+ """Create a shimmer animation effect on text (like Claude Code's 'Working' indicator)."""
63
+ text = Text()
64
+ frame_idx = int(elapsed * 8) % len(_SPINNER_FRAMES)
65
+ text.append(f" {_SPINNER_FRAMES[frame_idx]} ", style="ms.accent")
66
+
67
+ # Shimmer: sweep a bright highlight across the label
68
+ cycle = 2.0 # seconds per sweep
69
+ phase = (elapsed % cycle) / cycle
70
+ for i, ch in enumerate(label):
71
+ char_pos = i / max(len(label) - 1, 1)
72
+ distance = abs(char_pos - phase)
73
+ brightness = max(0, 1.0 - distance * 3)
74
+ if brightness > 0.5:
75
+ text.append(ch, style="bold #ffffff")
76
+ elif brightness > 0.2:
77
+ text.append(ch, style="#e2e8f0")
78
+ else:
79
+ text.append(ch, style="ms.dim")
80
+
81
+ # Elapsed time
82
+ if elapsed >= 60:
83
+ mins = int(elapsed // 60)
84
+ secs = int(elapsed % 60)
85
+ time_str = f" {mins}m {secs}s"
86
+ else:
87
+ time_str = f" {elapsed:.0f}s"
88
+ text.append(time_str, style="ms.dim")
89
+ text.append(" press Esc to interrupt", style="ms.dim")
90
+ return text
91
+
92
+
93
+ # ─── Status Bar ────────────────────────────────────────────────────────
94
+
95
+ def status_bar(
96
+ model: str = "",
97
+ cost: float = 0.0,
98
+ tokens: int = 0,
99
+ mode: str = "agent",
100
+ project: str = "",
101
+ tier: str = "free",
102
+ credits: float = 0.0,
103
+ ) -> Text:
104
+ """Render a bottom status bar like Claude Code / Gemini CLI."""
105
+ width = console.width or 80
106
+ bar = Text()
107
+
108
+ # Left side: model + mode
109
+ model_short = model.split("/")[-1] if "/" in model else model
110
+ bar.append(f" {model_short} ", style="ms.status.key")
111
+ bar.append(" ", style="ms.status")
112
+
113
+ mode_style = "bold #22c55e on #1e293b" if mode == "agent" else "bold #f59e0b on #1e293b"
114
+ bar.append(f" {mode.upper()} ", style=mode_style)
115
+ bar.append(" ", style="ms.status")
116
+
117
+ # Center: project
118
+ proj_short = project[:20] if project else "~"
119
+ bar.append(f" {proj_short} ", style="ms.status")
120
+
121
+ # Right side: cost + tokens + credits
122
+ right_parts = []
123
+ if tokens > 0:
124
+ right_parts.append(f"{tokens:,} tok")
125
+ if cost > 0:
126
+ right_parts.append(f"${cost:.4f}")
127
+ right_parts.append(f"${credits:.2f} fuel")
128
+ right_parts.append(tier)
129
+ right_text = " ".join(right_parts)
130
+
131
+ # Pad to fill width
132
+ used = bar.cell_len + len(right_text) + 2
133
+ padding = max(0, width - used)
134
+ bar.append(" " * padding, style="ms.status")
135
+ bar.append(f" {right_text} ", style="ms.status")
136
+
137
+ return bar
138
+
139
+
140
+ # ─── Tool Execution Display ───────────────────────────────────────────
141
+
142
+ def tool_header(tool_name: str, args: Dict[str, Any]) -> Panel:
143
+ """Render a tool execution header with icon and args summary."""
144
+ icons = {
145
+ "read_file": "📖",
146
+ "write_file": "📝",
147
+ "edit_file": "✏️",
148
+ "run_command": "⚡",
149
+ "glob_files": "🔍",
150
+ "grep_search": "🔎",
151
+ "web_search": "🌐",
152
+ }
153
+ icon = icons.get(tool_name, "🔧")
154
+
155
+ # Format args concisely
156
+ if tool_name in ("read_file", "write_file", "edit_file"):
157
+ summary = args.get("path", "")
158
+ elif tool_name == "run_command":
159
+ summary = args.get("command", "")[:60]
160
+ elif tool_name == "glob_files":
161
+ summary = args.get("pattern", "")
162
+ elif tool_name == "grep_search":
163
+ summary = f"{args.get('pattern', '')} in {args.get('path', '.')}"
164
+ elif tool_name == "web_search":
165
+ summary = args.get("query", "")[:60]
166
+ else:
167
+ summary = str(args)[:60]
168
+
169
+ title = Text()
170
+ title.append(f" {icon} ", style="ms.tool")
171
+ title.append(tool_name, style="ms.tool")
172
+ title.append(f" {summary}", style="ms.file")
173
+
174
+ return Panel(title, border_style="ms.dim", padding=(0, 1), expand=True)
175
+
176
+
177
+ def tool_result_display(tool_name: str, result: str, args: Dict[str, Any] = None) -> Panel:
178
+ """Render tool result with appropriate formatting."""
179
+ args = args or {}
180
+
181
+ # For edit_file, show a mini diff
182
+ if tool_name == "edit_file" and "Edited" in result:
183
+ old_s = args.get("old_string", "")
184
+ new_s = args.get("new_string", "")
185
+ diff_text = _make_inline_diff(old_s, new_s, args.get("path", ""))
186
+ content = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
187
+ return Panel(content, border_style="ms.accent2", title="[ms.accent2]edit applied[/ms.accent2]", padding=(0, 1))
188
+
189
+ # For run_command, show as terminal output
190
+ if tool_name == "run_command":
191
+ lines = result.strip().split("\n")
192
+ display = "\n".join(lines[:20])
193
+ if len(lines) > 20:
194
+ display += f"\n... ({len(lines) - 20} more lines)"
195
+ return Panel(
196
+ Text(display, style="ms.muted"),
197
+ border_style="ms.dim",
198
+ title="[ms.dim]output[/ms.dim]",
199
+ padding=(0, 1),
200
+ )
201
+
202
+ # For read_file, show truncated with syntax highlighting
203
+ if tool_name == "read_file" and len(result) > 200:
204
+ path = args.get("path", "")
205
+ ext = path.rsplit(".", 1)[-1] if "." in path else ""
206
+ lang = {"py": "python", "ts": "typescript", "js": "javascript", "tsx": "tsx", "rs": "rust", "go": "go"}.get(ext, ext)
207
+ lines = result.split("\n")
208
+ preview = "\n".join(lines[:15])
209
+ if len(lines) > 15:
210
+ preview += f"\n... ({len(lines) - 15} more lines)"
211
+ return Panel(
212
+ Syntax(preview, lang or "text", theme="monokai", line_numbers=True),
213
+ border_style="ms.dim",
214
+ title=f"[ms.file]{path}[/ms.file]",
215
+ padding=(0, 1),
216
+ )
217
+
218
+ # Default: plain text
219
+ display = result[:500]
220
+ if len(result) > 500:
221
+ display += "..."
222
+ return Panel(Text(display, style="ms.muted"), border_style="ms.dim", padding=(0, 1))
223
+
224
+
225
+ def _make_inline_diff(old_string: str, new_string: str, path: str = "") -> str:
226
+ """Create a minimal inline diff for display."""
227
+ lines = []
228
+ lines.append(f"--- a/{path}")
229
+ lines.append(f"+++ b/{path}")
230
+ lines.append("@@ @@")
231
+ for line in old_string.splitlines():
232
+ lines.append(f"- {line}")
233
+ for line in new_string.splitlines():
234
+ lines.append(f"+ {line}")
235
+ return "\n".join(lines)
236
+
237
+
238
+ # ─── Prompt Rendering ──────────────────────────────────────────────────
239
+
240
+ def render_prompt() -> str:
241
+ """Return the styled prompt string for input."""
242
+ return "[ms.prompt]❯[/ms.prompt] "
243
+
244
+
245
+ def render_user_message(text: str):
246
+ """Display a user message in the conversation."""
247
+ console.print()
248
+ msg = Text()
249
+ msg.append("❯ ", style="ms.prompt")
250
+ msg.append(text, style="ms.user")
251
+ console.print(msg)
252
+
253
+
254
+ def render_assistant_response(text: str):
255
+ """Display the assistant's final response."""
256
+ console.print()
257
+ console.print(Markdown(text))
258
+
259
+
260
+ def render_cost_footer(tokens: int, cost: float):
261
+ """Show a compact cost footer after a response."""
262
+ footer = Text()
263
+ footer.append(f" {tokens:,} tokens", style="ms.dim")
264
+ footer.append(f" ${cost:.4f}", style="ms.cost")
265
+ console.print(footer)
266
+
267
+
268
+ # ─── Session Header ───────────────────────────────────────────────────
269
+
270
+ def render_session_header(
271
+ model: str,
272
+ tier: str,
273
+ credits: float,
274
+ project: str,
275
+ agent_mode: bool,
276
+ instructions_loaded: bool = False,
277
+ memories_loaded: bool = False,
278
+ ):
279
+ """Render the session startup header."""
280
+ console.print()
281
+
282
+ # Title
283
+ title = Text()
284
+ title.append(" ◆ ", style="ms.accent")
285
+ title.append("MSapling", style="bold #ffffff")
286
+ title.append(" v0.1.0", style="ms.dim")
287
+
288
+ # Info lines
289
+ info = Text()
290
+ info.append(f"\n Model ", style="ms.dim")
291
+ info.append(model, style="ms.accent")
292
+ info.append(f"\n Project ", style="ms.dim")
293
+ info.append(project, style="ms.file")
294
+ info.append(f"\n Tier ", style="ms.dim")
295
+ info.append(tier, style="ms.accent2" if tier != "free" else "ms.warn")
296
+ info.append(f" • ${credits:.4f} fuel", style="ms.cost")
297
+
298
+ mode_text = "Agent mode" if agent_mode else "Simple mode"
299
+ mode_style = "ms.accent2" if agent_mode else "ms.warn"
300
+ info.append(f"\n Mode ", style="ms.dim")
301
+ info.append(mode_text, style=mode_style)
302
+ if agent_mode:
303
+ info.append(" (AI reads files, writes code, runs commands)", style="ms.dim")
304
+
305
+ # Context loaded
306
+ if instructions_loaded or memories_loaded:
307
+ info.append(f"\n Context ", style="ms.dim")
308
+ parts = []
309
+ if instructions_loaded:
310
+ parts.append("project instructions")
311
+ if memories_loaded:
312
+ parts.append("memories")
313
+ info.append(", ".join(parts), style="ms.dim")
314
+
315
+ console.print(Panel(
316
+ Group(title, info),
317
+ border_style="ms.accent",
318
+ padding=(0, 1),
319
+ ))
320
+
321
+ # Hint line
322
+ hint = Text()
323
+ hint.append(" /help", style="ms.accent")
324
+ hint.append(" commands ", style="ms.dim")
325
+ hint.append("/simple", style="ms.accent")
326
+ hint.append(" chat-only ", style="ms.dim")
327
+ hint.append("Ctrl+C", style="ms.accent")
328
+ hint.append(" exit", style="ms.dim")
329
+ console.print(hint)
330
+ console.print()
331
+
332
+
333
+ # ─── Approval Prompt ──────────────────────────────────────────────────
334
+
335
+ def render_approval_prompt(tool_name: str, args: Dict[str, Any]) -> str:
336
+ """Show a styled approval prompt. Returns 'y', 'n', or 'always'."""
337
+ icons = {
338
+ "write_file": "📝",
339
+ "edit_file": "✏️",
340
+ "run_command": "⚡",
341
+ }
342
+ icon = icons.get(tool_name, "🔧")
343
+
344
+ console.print()
345
+ if tool_name == "write_file":
346
+ path = args.get("path", "?")
347
+ content = args.get("content", "")
348
+ lines = content.split("\n")
349
+ preview = "\n".join(lines[:10])
350
+ if len(lines) > 10:
351
+ preview += f"\n... ({len(lines) - 10} more lines)"
352
+ console.print(Panel(
353
+ Group(
354
+ Text(f"Path: {path}", style="ms.file"),
355
+ Text(),
356
+ Syntax(preview, _lang_from_path(path), theme="monokai"),
357
+ ),
358
+ title=f"[ms.warn]{icon} write_file[/ms.warn]",
359
+ border_style="ms.warn",
360
+ padding=(0, 1),
361
+ ))
362
+ elif tool_name == "edit_file":
363
+ path = args.get("path", "?")
364
+ old_s = args.get("old_string", "")
365
+ new_s = args.get("new_string", "")
366
+ diff = _make_inline_diff(old_s, new_s, path)
367
+ console.print(Panel(
368
+ Syntax(diff, "diff", theme="monokai"),
369
+ title=f"[ms.warn]{icon} edit_file → {path}[/ms.warn]",
370
+ border_style="ms.warn",
371
+ padding=(0, 1),
372
+ ))
373
+ elif tool_name == "run_command":
374
+ cmd = args.get("command", "")
375
+ console.print(Panel(
376
+ Text(f"$ {cmd}", style="bold"),
377
+ title=f"[ms.warn]{icon} run_command[/ms.warn]",
378
+ border_style="ms.warn",
379
+ padding=(0, 1),
380
+ ))
381
+ else:
382
+ console.print(Panel(
383
+ Text(f"{tool_name}: {args}", style="ms.muted"),
384
+ title=f"[ms.warn]{icon} {tool_name}[/ms.warn]",
385
+ border_style="ms.warn",
386
+ ))
387
+
388
+ while True:
389
+ answer = console.input("[ms.warn] Allow? [/ms.warn][ms.dim](y/n/always)[/ms.dim] ").strip().lower()
390
+ if answer in ("y", "yes"):
391
+ return "y"
392
+ if answer in ("n", "no"):
393
+ return "n"
394
+ if answer in ("always", "a"):
395
+ return "always"
396
+ console.print(" [ms.dim]Enter y, n, or always[/ms.dim]")
397
+
398
+
399
+ def _lang_from_path(path: str) -> str:
400
+ ext = path.rsplit(".", 1)[-1] if "." in path else ""
401
+ return {"py": "python", "ts": "typescript", "js": "javascript", "tsx": "tsx", "rs": "rust", "go": "go", "rb": "ruby", "java": "java", "md": "markdown"}.get(ext, "text")
402
+
403
+
404
+ # ─── Slash Command Help ───────────────────────────────────────────────
405
+
406
+ def render_help():
407
+ """Render styled help panel."""
408
+ sections = [
409
+ ("Session", [
410
+ ("/help", "Show this help"),
411
+ ("/clear", "Clear conversation"),
412
+ ("/compact", "Compress context (LLM-summarized)"),
413
+ ("/context", "Show context usage"),
414
+ ("/save", "Save session"),
415
+ ("/resume", "Resume previous session"),
416
+ ("/fork", "Fork conversation"),
417
+ ("/rewind [n]", "Undo last n messages"),
418
+ ("/quit", "Exit"),
419
+ ]),
420
+ ("Model & Mode", [
421
+ ("/model <id>", "Switch model"),
422
+ ("/models", "List available models"),
423
+ ("/agent", "Agent mode (AI uses tools)"),
424
+ ("/simple", "Simple chat (no tools)"),
425
+ ("/plan", "Plan mode (suggest only)"),
426
+ ("/vim", "Multiline input"),
427
+ ("/effort <lvl>", "low / medium / high"),
428
+ ]),
429
+ ("Files", [
430
+ ("/files [glob]", "List files"),
431
+ ("/read <path>", "Read file into context"),
432
+ ("/write <path>", "Write file"),
433
+ ("/edit <path>", "AI-edit file"),
434
+ ("/grep <pattern>", "Search file contents"),
435
+ ("/mention <path>", "Attach to next message"),
436
+ ("/image <path>", "Attach image"),
437
+ ]),
438
+ ("Terminal & Git", [
439
+ ("/run <cmd>", "Execute command"),
440
+ ("/git <args>", "Git command"),
441
+ ("/diff", "Git diff"),
442
+ ("/status", "Git status"),
443
+ ("/worktree", "Git worktree management"),
444
+ ]),
445
+ ("Multi-Model (Pro)", [
446
+ ("/multi <prompt>", "Parallel comparison"),
447
+ ("/swarm <prompt>", "Parallel + synthesis"),
448
+ ("/agent spawn", "Background subagent"),
449
+ ]),
450
+ ("Workers (Multi-Chat)", [
451
+ ("/workers", "List parallel workers"),
452
+ ("/join <name|#>", "Switch to worker"),
453
+ ("/broadcast <msg>", "Message all workers"),
454
+ ("/collect", "Show worker outputs"),
455
+ ("/kill <name|#>", "Remove a worker"),
456
+ ("/budget [amt]", "Set cost ceiling"),
457
+ ]),
458
+ ("System", [
459
+ ("/project", "Project info"),
460
+ ("/init", "Generate .msapling.md"),
461
+ ("/cost", "Session cost"),
462
+ ("/memory", "Persistent memory"),
463
+ ("/hooks", "Lifecycle hooks"),
464
+ ("/mdrive", "Cloud file sync"),
465
+ ("/whoami", "Account info"),
466
+ ]),
467
+ ]
468
+
469
+ for section_name, commands in sections:
470
+ table = Table(show_header=False, border_style="ms.dim", padding=(0, 1), expand=True)
471
+ table.add_column("Command", style="ms.accent", min_width=20)
472
+ table.add_column("Description", style="ms.muted")
473
+ for cmd, desc in commands:
474
+ table.add_row(cmd, desc)
475
+ console.print(Panel(table, title=f"[ms.accent]{section_name}[/ms.accent]", border_style="ms.dim", padding=(0, 0)))