gemi-cli 0.1.0__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.
gemi/ui.py ADDED
@@ -0,0 +1,387 @@
1
+ from rich.columns import Columns
2
+ from rich.console import Console
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+ from rich.text import Text
6
+
7
+ console = Console()
8
+
9
+ BANNER = r"""
10
+ __ _ ___ _ __ ___ (_)
11
+ / _` / _ \ '_ ` _ \| |
12
+ | (_| \__/ | | | | | |
13
+ \__, \___|_| |_| |_|_|
14
+ __/ |
15
+ |___/
16
+ """
17
+
18
+
19
+ def print_banner():
20
+ banner_text = Text(BANNER, style="bold cyan")
21
+ version_line = Text(" v0.1.0", style="bold cyan")
22
+ version_line.append(" — Free AI Coding Agent", style="dim")
23
+ console.print(banner_text, end="")
24
+ console.print(version_line)
25
+ console.print()
26
+
27
+
28
+ def print_welcome(provider: str, model: str, cwd: str):
29
+ panels = Columns([
30
+ Panel(f"[bold green]{provider}[/bold green]", title="[dim]Provider[/dim]", border_style="cyan", padding=(0, 1), expand=True),
31
+ Panel(f"[bold green]{model}[/bold green]", title="[dim]Model[/dim]", border_style="cyan", padding=(0, 1), expand=True),
32
+ ], padding=(0, 1), expand=True)
33
+ console.print(panels)
34
+ console.print(Panel(f"[blue]{cwd}[/blue]", title="[dim]Directory[/dim]", border_style="cyan", padding=(0, 1)))
35
+ console.print(
36
+ " [dim]Type[/dim] [bold]/help[/bold] [dim]for commands,[/dim] "
37
+ "[bold]/quit[/bold] [dim]to exit[/dim]\n"
38
+ )
39
+
40
+
41
+ def print_key_status(status: list[dict]):
42
+ table = Table(
43
+ show_header=True,
44
+ header_style="bold",
45
+ border_style="dim",
46
+ title="Key Status",
47
+ title_style="bold",
48
+ padding=(0, 1),
49
+ )
50
+ table.add_column("Provider", style="cyan")
51
+ table.add_column("Name", style="white")
52
+ table.add_column("State")
53
+ table.add_column("Requests", justify="right", style="dim")
54
+ table.add_column("Tokens", justify="right", style="dim")
55
+
56
+ for entry in status:
57
+ state_style = "green"
58
+ if entry["state"] == "exhausted":
59
+ state_style = "red"
60
+ elif "cooldown" in entry["state"]:
61
+ state_style = "yellow"
62
+ elif entry["state"] == "standby":
63
+ state_style = "dim"
64
+
65
+ table.add_row(
66
+ entry["provider"],
67
+ entry["name"],
68
+ Text(entry["state"], style=state_style),
69
+ str(entry["requests"]),
70
+ str(entry["tokens"]),
71
+ )
72
+
73
+ console.print(table)
74
+
75
+
76
+ def print_help():
77
+ help_text = (
78
+ "[bold cyan]Session Commands[/bold cyan]\n"
79
+ " [bold]/help[/bold] Show this help\n"
80
+ " [bold]/status[/bold] Key rotation status & usage\n"
81
+ " [bold]/tokens[/bold] Detailed token stats\n"
82
+ " [bold]/model[/bold] [dim]<m>[/dim] Switch model mid-session\n"
83
+ " [bold]/plan[/bold] View current plan progress\n"
84
+ " [bold]/undo[/bold] Undo the last file edit\n"
85
+ " [bold]/sessions[/bold] List saved sessions\n"
86
+ " [bold]/clear[/bold] Clear conversation history\n"
87
+ " [bold]/quit[/bold] Exit gemi\n"
88
+ "\n"
89
+ "[bold cyan]Tips[/bold cyan]\n"
90
+ " Resume a session: [bold]gemi --resume <id>[/bold]\n"
91
+ " Project context: Create a [bold].gemi.md[/bold] in your project root"
92
+ )
93
+ console.print(Panel(help_text, title="[bold]gemi help[/bold]", border_style="cyan", padding=(1, 2)))
94
+
95
+
96
+ def print_tool_call(name: str, args: dict):
97
+ TOOL_ICONS = {
98
+ "read_file": "📄",
99
+ "write_file": "📝",
100
+ "edit_file": "✏️ ",
101
+ "run_command": "⚙️ ",
102
+ "list_directory": "📁",
103
+ "search_files": "🔍",
104
+ "find_files": "🔍",
105
+ "git_status": "📊",
106
+ "git_diff": "📊",
107
+ "git_log": "📊",
108
+ "git_commit": "💾",
109
+ "git_branch": "🌿",
110
+ "create_plan": "📋",
111
+ }
112
+ icon = TOOL_ICONS.get(name, "⚡")
113
+
114
+ if name == "create_plan":
115
+ return
116
+
117
+ if name == "read_file":
118
+ label = f"Read {args.get('path', '')}"
119
+ elif name == "write_file":
120
+ label = f"Write {args.get('path', '')}"
121
+ elif name == "edit_file":
122
+ label = f"Edit {args.get('path', '')}"
123
+ elif name == "run_command":
124
+ cmd = args.get("command", "")
125
+ label = f"`{cmd[:60]}{'...' if len(cmd) > 60 else ''}`"
126
+ elif name == "list_directory":
127
+ label = f"List {args.get('path', '.')}"
128
+ elif name == "search_files":
129
+ label = f"Search '{args.get('pattern', '')}'"
130
+ elif name == "find_files":
131
+ label = f"Find '{args.get('pattern', '')}'"
132
+ elif name == "git_commit":
133
+ label = f"Commit: {args.get('message', '')[:50]}"
134
+ else:
135
+ label = name.replace("_", " ").title()
136
+
137
+ console.print(f"\n {icon} [bold dim]{label}[/bold dim]")
138
+
139
+
140
+ def print_tool_result(name: str, result: str):
141
+ if not result.strip():
142
+ return
143
+
144
+ if name == "edit_file" and "Diff:" in result:
145
+ parts = result.split("Diff:\n", 1)
146
+ if len(parts) == 2:
147
+ console.print(f" [green]{parts[0].strip()}[/green]")
148
+ print_diff(parts[1])
149
+ return
150
+
151
+ if name == "write_file":
152
+ console.print(f" [green]{result.strip()}[/green]")
153
+ return
154
+
155
+ if name == "run_command":
156
+ lines = result.strip().splitlines()
157
+ preview = lines[:5]
158
+ text = "\n".join(preview)
159
+ if len(lines) > 5:
160
+ text += f"\n... ({len(lines)} lines total)"
161
+ console.print(Panel(text, border_style="dim", padding=(0, 1), expand=False))
162
+ return
163
+
164
+ lines = result.strip().splitlines()
165
+ if len(lines) > 3:
166
+ preview = "\n".join(lines[:3]) + f"\n[dim]... ({len(lines)} lines)[/dim]"
167
+ else:
168
+ preview = result.strip()
169
+ console.print(f" [dim]{preview[:300]}[/dim]")
170
+
171
+
172
+ def print_diff(diff_text: str):
173
+ lines = diff_text.strip().splitlines()
174
+ output = Text()
175
+ for line in lines:
176
+ stripped = line.lstrip()
177
+ if not stripped:
178
+ output.append(f" {line}\n", style="dim")
179
+ elif " + " in line and (stripped[0].isdigit() or stripped.startswith("+")):
180
+ output.append(f" {line}\n", style="white on green")
181
+ elif " - " in line and (stripped[0].isdigit() or stripped.startswith("-")):
182
+ output.append(f" {line}\n", style="white on red")
183
+ elif stripped.startswith("+ "):
184
+ output.append(f" {line}\n", style="white on green")
185
+ elif stripped.startswith("- "):
186
+ output.append(f" {line}\n", style="white on red")
187
+ else:
188
+ output.append(f" {line}\n", style="dim")
189
+ console.print(Panel(output, border_style="dim", title="[dim]diff[/dim]", padding=(0, 1), expand=False))
190
+
191
+
192
+ def print_approval_prompt(name: str, args: dict) -> bool:
193
+ console.print()
194
+
195
+ if name == "edit_file":
196
+ console.print(f" [bold yellow]Edit file:[/bold yellow] [blue]{args.get('path', '')}[/blue]")
197
+ old = args.get("old_text", "")
198
+ new = args.get("new_text", "")
199
+
200
+ start_line = 1
201
+ try:
202
+ from pathlib import Path
203
+ file_content = Path(args.get("path", "")).read_text()
204
+ pos = file_content.find(old)
205
+ if pos >= 0:
206
+ start_line = file_content[:pos].count("\n") + 1
207
+ except Exception:
208
+ pass
209
+
210
+ diff = Text()
211
+ ln = start_line
212
+ for line in old.splitlines():
213
+ diff.append(f" {ln:4d} - {line}\n", style="white on red")
214
+ ln += 1
215
+ ln = start_line
216
+ for line in new.splitlines():
217
+ diff.append(f" {ln:4d} + {line}\n", style="white on green")
218
+ ln += 1
219
+ console.print(Panel(diff, border_style="dim", title="[dim]proposed change[/dim]", padding=(0, 1), expand=False))
220
+ elif name == "write_file":
221
+ path = args.get("path", "")
222
+ content = args.get("content", "")
223
+ lines = content.splitlines()
224
+ console.print(f" [bold yellow]Write file:[/bold yellow] [blue]{path}[/blue] ({len(lines)} lines)")
225
+ diff = Text()
226
+ for ln, line in enumerate(lines, 1):
227
+ diff.append(f" {ln:4d} + {line}\n", style="white on green")
228
+ if len(lines) > 50:
229
+ shown = Text()
230
+ for ln, line in enumerate(lines[:40], 1):
231
+ shown.append(f" {ln:4d} + {line}\n", style="white on green")
232
+ shown.append(f"\n ... {len(lines) - 40} more lines ...\n", style="dim")
233
+ console.print(Panel(shown, border_style="dim", title="[dim]new file[/dim]", padding=(0, 1), expand=False))
234
+ else:
235
+ console.print(Panel(diff, border_style="dim", title="[dim]new file[/dim]", padding=(0, 1), expand=False))
236
+ elif name == "run_command":
237
+ cmd = args.get("command", "")
238
+ console.print(f" [bold yellow]Run command:[/bold yellow]")
239
+ console.print(Panel(cmd, border_style="yellow", padding=(0, 1), expand=False))
240
+ elif name == "git_commit":
241
+ msg = args.get("message", "")
242
+ files = args.get("files", "all changes")
243
+ console.print(f" [bold yellow]Git commit:[/bold yellow] {msg}")
244
+ console.print(f" [dim]Files: {files}[/dim]")
245
+ else:
246
+ console.print(f" [bold yellow]{name}[/bold yellow]")
247
+ for k, v in args.items():
248
+ display = str(v)[:200]
249
+ console.print(f" {k}: {display}")
250
+
251
+ return _interactive_select(
252
+ options=["a) Yes, allow", "b) No, deny"],
253
+ default=0,
254
+ ) == 0
255
+
256
+
257
+ def _read_key() -> str:
258
+ import sys
259
+ import tty
260
+ import termios
261
+
262
+ fd = sys.stdin.fileno()
263
+ old = termios.tcgetattr(fd)
264
+ try:
265
+ tty.setraw(fd)
266
+ ch = sys.stdin.read(1)
267
+ if ch == "\x1b":
268
+ ch2 = sys.stdin.read(1)
269
+ if ch2 == "[":
270
+ ch3 = sys.stdin.read(1)
271
+ if ch3 == "A":
272
+ return "up"
273
+ elif ch3 == "B":
274
+ return "down"
275
+ return "esc"
276
+ elif ch in ("\r", "\n"):
277
+ return "enter"
278
+ elif ch == "\x03":
279
+ return "ctrl-c"
280
+ elif ch in ("j",):
281
+ return "down"
282
+ elif ch in ("k",):
283
+ return "up"
284
+ return ch
285
+ finally:
286
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
287
+
288
+
289
+ def _interactive_select(options: list[str], default: int = 0) -> int:
290
+ import sys
291
+
292
+ selected = default
293
+
294
+ def _render():
295
+ lines = []
296
+ for i, opt in enumerate(options):
297
+ if i == selected:
298
+ if i == 0:
299
+ lines.append(f" [bold white on green] {opt} [/bold white on green]")
300
+ else:
301
+ lines.append(f" [bold white on red] {opt} [/bold white on red]")
302
+ else:
303
+ lines.append(f" [dim] {opt} [/dim]")
304
+ return "\n".join(lines)
305
+
306
+ console.print()
307
+ console.print(_render())
308
+ console.print(f"\n [dim]↑↓ to select, Enter to confirm[/dim]")
309
+
310
+ num_lines = len(options) + 2
311
+
312
+ while True:
313
+ try:
314
+ key = _read_key()
315
+ except (EOFError, KeyboardInterrupt):
316
+ sys.stdout.write(f"\033[{num_lines}A\033[J")
317
+ sys.stdout.flush()
318
+ console.print(f" [red]Denied[/red]")
319
+ return 1
320
+
321
+ if key == "up":
322
+ selected = (selected - 1) % len(options)
323
+ elif key == "down":
324
+ selected = (selected + 1) % len(options)
325
+ elif key == "enter":
326
+ sys.stdout.write(f"\033[{num_lines}A\033[J")
327
+ sys.stdout.flush()
328
+ chosen = options[selected]
329
+ if selected == 0:
330
+ console.print(f" [green]{chosen}[/green]")
331
+ else:
332
+ console.print(f" [red]{chosen}[/red]")
333
+ return selected
334
+ elif key == "ctrl-c" or key == "esc":
335
+ sys.stdout.write(f"\033[{num_lines}A\033[J")
336
+ sys.stdout.flush()
337
+ console.print(f" [red]Denied[/red]")
338
+ return 1
339
+
340
+ sys.stdout.write(f"\033[{num_lines}A\033[J")
341
+ sys.stdout.flush()
342
+ console.print()
343
+ console.print(_render())
344
+ console.print(f"\n [dim]↑↓ to select, Enter to confirm[/dim]")
345
+
346
+
347
+ def print_status_bar(status_line: str):
348
+ console.print(f"\n [dim]{status_line}[/dim]")
349
+
350
+
351
+ def print_plan(title: str, steps: list[dict], show_status: bool = True):
352
+ lines = []
353
+ for i, step in enumerate(steps, 1):
354
+ status = step.get("status", "pending")
355
+ if status == "done":
356
+ icon = "[green]✓[/green]"
357
+ elif status == "in_progress":
358
+ icon = "[yellow]▶[/yellow]"
359
+ elif status == "failed":
360
+ icon = "[red]✗[/red]"
361
+ else:
362
+ icon = "[dim]○[/dim]"
363
+
364
+ step_title = step.get("title", f"Step {i}")
365
+ desc = step.get("description", "")
366
+
367
+ if status == "in_progress":
368
+ lines.append(f" {icon} [bold]{i}. {step_title}[/bold]")
369
+ elif status == "done":
370
+ lines.append(f" {icon} [green]{i}. {step_title}[/green]")
371
+ elif status == "failed":
372
+ lines.append(f" {icon} [red]{i}. {step_title}[/red]")
373
+ else:
374
+ lines.append(f" {icon} [dim]{i}. {step_title}[/dim]")
375
+
376
+ if desc and status != "done":
377
+ lines.append(f" [dim]{desc}[/dim]")
378
+
379
+ content = "\n".join(lines)
380
+ console.print(Panel(content, title=f"[bold cyan]{title}[/bold cyan]", border_style="cyan", padding=(1, 1)))
381
+
382
+
383
+ def print_plan_approval() -> bool:
384
+ return _interactive_select(
385
+ options=["a) Yes, execute this plan", "b) No, let me modify it"],
386
+ default=0,
387
+ ) == 0