indent 0.0.8__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.

Potentially problematic release.


This version of indent might be problematic. Click here for more details.

Files changed (56) hide show
  1. exponent/__init__.py +1 -0
  2. exponent/cli.py +112 -0
  3. exponent/commands/cloud_commands.py +85 -0
  4. exponent/commands/common.py +434 -0
  5. exponent/commands/config_commands.py +581 -0
  6. exponent/commands/github_app_commands.py +211 -0
  7. exponent/commands/listen_commands.py +96 -0
  8. exponent/commands/run_commands.py +208 -0
  9. exponent/commands/settings.py +56 -0
  10. exponent/commands/shell_commands.py +2840 -0
  11. exponent/commands/theme.py +246 -0
  12. exponent/commands/types.py +111 -0
  13. exponent/commands/upgrade.py +29 -0
  14. exponent/commands/utils.py +236 -0
  15. exponent/core/config.py +180 -0
  16. exponent/core/graphql/__init__.py +0 -0
  17. exponent/core/graphql/client.py +59 -0
  18. exponent/core/graphql/cloud_config_queries.py +77 -0
  19. exponent/core/graphql/get_chats_query.py +47 -0
  20. exponent/core/graphql/github_config_queries.py +56 -0
  21. exponent/core/graphql/mutations.py +75 -0
  22. exponent/core/graphql/queries.py +110 -0
  23. exponent/core/graphql/subscriptions.py +452 -0
  24. exponent/core/remote_execution/checkpoints.py +212 -0
  25. exponent/core/remote_execution/cli_rpc_types.py +214 -0
  26. exponent/core/remote_execution/client.py +545 -0
  27. exponent/core/remote_execution/code_execution.py +58 -0
  28. exponent/core/remote_execution/command_execution.py +105 -0
  29. exponent/core/remote_execution/error_info.py +45 -0
  30. exponent/core/remote_execution/exceptions.py +10 -0
  31. exponent/core/remote_execution/file_write.py +410 -0
  32. exponent/core/remote_execution/files.py +415 -0
  33. exponent/core/remote_execution/git.py +268 -0
  34. exponent/core/remote_execution/languages/python_execution.py +239 -0
  35. exponent/core/remote_execution/languages/shell_streaming.py +221 -0
  36. exponent/core/remote_execution/languages/types.py +20 -0
  37. exponent/core/remote_execution/session.py +128 -0
  38. exponent/core/remote_execution/system_context.py +54 -0
  39. exponent/core/remote_execution/tool_execution.py +289 -0
  40. exponent/core/remote_execution/truncation.py +284 -0
  41. exponent/core/remote_execution/types.py +670 -0
  42. exponent/core/remote_execution/utils.py +600 -0
  43. exponent/core/types/__init__.py +0 -0
  44. exponent/core/types/command_data.py +206 -0
  45. exponent/core/types/event_types.py +89 -0
  46. exponent/core/types/generated/__init__.py +0 -0
  47. exponent/core/types/generated/strategy_info.py +225 -0
  48. exponent/migration-docs/login.md +112 -0
  49. exponent/py.typed +4 -0
  50. exponent/utils/__init__.py +0 -0
  51. exponent/utils/colors.py +92 -0
  52. exponent/utils/version.py +289 -0
  53. indent-0.0.8.dist-info/METADATA +36 -0
  54. indent-0.0.8.dist-info/RECORD +56 -0
  55. indent-0.0.8.dist-info/WHEEL +4 -0
  56. indent-0.0.8.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,246 @@
1
+ import logging
2
+ import os
3
+ import select
4
+ import sys
5
+
6
+ from colour import Color
7
+
8
+ from exponent.utils.colors import (
9
+ adjust_color_for_contrast,
10
+ blend_colors_srgb,
11
+ color_distance,
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ DEFAULT_TERM_PALETTE = [
18
+ # Windows 10 Console default palette.
19
+ # The least bad default of them all ;)
20
+ # Used as a fallback when terminal doesn't report its palette,
21
+ # which only happens on older or feature-poor terminals, i.e. very rarely.
22
+ Color("#0c0c0c"),
23
+ Color("#c50f1f"),
24
+ Color("#13a10e"),
25
+ Color("#c19c00"),
26
+ Color("#0037da"),
27
+ Color("#881798"),
28
+ Color("#3a96dd"),
29
+ Color("#cccccc"),
30
+ ]
31
+
32
+
33
+ # As the only popular "modern" terminal emulator, Terminal.app doesn't support true color
34
+ # so disable true color output if we're running in it.
35
+ TRUE_COLOR = os.getenv("TERM_PROGRAM") != "Apple_Terminal"
36
+
37
+
38
+ class Theme:
39
+ term_fg: Color
40
+ term_bg: Color
41
+ red: Color
42
+ green: Color
43
+ blue: Color
44
+ exponent_green: Color
45
+ hl_theme_name: str
46
+ dimmed_text_fg: Color
47
+ block_header_bg: Color
48
+ block_body_bg: Color
49
+ block_footer_fg: Color
50
+ block_footer_bg: Color
51
+ statusbar_default_fg: Color
52
+ statusbar_autorun_all: Color
53
+ statusbar_autorun_ro: Color
54
+ statusbar_thinking_on: Color
55
+ thinking_spinner_fg: Color
56
+
57
+ def __init__(
58
+ self, term_fg: Color, term_bg: Color, term_palette: list[Color | None]
59
+ ):
60
+ self.term_fg = term_bg
61
+ self.term_bg = term_bg
62
+ self.red = adjust_color_for_contrast(
63
+ term_bg, term_palette[1] or DEFAULT_TERM_PALETTE[1]
64
+ )
65
+ self.green = adjust_color_for_contrast(
66
+ term_bg, term_palette[2] or DEFAULT_TERM_PALETTE[2]
67
+ )
68
+ self.blue = adjust_color_for_contrast(
69
+ term_bg, term_palette[4] or DEFAULT_TERM_PALETTE[4]
70
+ )
71
+ self.exponent_green = adjust_color_for_contrast(
72
+ term_bg,
73
+ Color("#03bd89"), # green used in Exponent logo
74
+ )
75
+ dark_mode = term_bg.luminance < 0.5
76
+ self.hl_theme_name = "ansi_dark" if dark_mode else "ansi_light"
77
+ direction = 1 if dark_mode else -1
78
+ (h, s, l) = term_bg.hsl # noqa: E741
79
+ block_target = Color(hsl=(h, s, min(1.0, l + 0.005 * direction)))
80
+ self.block_header_bg = adjust_color_for_contrast(term_bg, block_target, 1.2)
81
+ self.block_body_bg = adjust_color_for_contrast(term_bg, block_target, 1.1)
82
+ self.block_footer_bg = adjust_color_for_contrast(term_bg, block_target, 1.05)
83
+ self.block_footer_fg = blend_colors_srgb(term_fg, self.block_footer_bg, 0.4)
84
+ self.dimmed_text_fg = adjust_color_for_contrast(
85
+ term_bg, blend_colors_srgb(term_fg, term_bg, 0.5)
86
+ )
87
+ self.statusbar_default_fg = self.dimmed_text_fg
88
+ self.statusbar_autorun_all = self.red
89
+ self.statusbar_autorun_ro = self.green
90
+ self.statusbar_thinking_on = self.blue
91
+ self.thinking_spinner_fg = adjust_color_for_contrast(
92
+ term_bg, Color("#968ce6")
93
+ ) # nice purple
94
+
95
+
96
+ def get_term_colors(
97
+ use_default_colors: bool,
98
+ ) -> tuple[Color, Color, list[Color | None]]:
99
+ from exponent.commands.shell_commands import POSIX_TERMINAL, RawMode
100
+
101
+ fg = DEFAULT_TERM_PALETTE[7]
102
+ bg = DEFAULT_TERM_PALETTE[0]
103
+ palette: list[Color | None] = [None, None, None, None, None, None, None, None]
104
+
105
+ # Not supported on Windows or when stdin is not a TTY
106
+ if use_default_colors or not POSIX_TERMINAL or not sys.stdin.isatty():
107
+ return (fg, bg, palette)
108
+
109
+ try:
110
+ with RawMode(sys.stdin.fileno()):
111
+ stdin_fd = sys.stdin.fileno()
112
+ stdout_fd = sys.stdout.fileno()
113
+
114
+ # Try to write the ANSI escape sequences
115
+ if stdout_fd in select.select([], [stdout_fd], [], 1)[1]:
116
+ os.write(
117
+ stdout_fd,
118
+ b"\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?\x07\x1b]4;1;?\x07\x1b]4;2;?\x07\x1b]4;3;?\x07\x1b]4;4;?\x07\x1b]4;5;?\x07\x1b]4;6;?\x07\x1b]4;7;?\x07\x1b[c",
119
+ )
120
+ else:
121
+ return (fg, bg, palette) # Can't write to stdout
122
+
123
+ got_da_response = False
124
+ timeout = 0.5 # Short timeout to avoid hanging
125
+
126
+ # Read terminal responses until we get DA response or timeout
127
+ while not got_da_response:
128
+ if stdin_fd in select.select([stdin_fd], [], [], timeout)[0]:
129
+ reply = os.read(stdin_fd, 1024)
130
+ seqs = reply.split(b"\x1b")
131
+
132
+ for seq in seqs:
133
+ if seq.startswith(b"]10;rgb:"):
134
+ [r, g, b] = seq[8:].decode().split("/")
135
+ fg = Color(f"#{r[0:2]}{g[0:2]}{b[0:2]}")
136
+ elif seq.startswith(b"]11;rgb:"):
137
+ [r, g, b] = seq[8:].decode().split("/")
138
+ bg = Color(f"#{r[0:2]}{g[0:2]}{b[0:2]}")
139
+ elif seq.startswith(b"]4;"):
140
+ text = seq.decode()
141
+ idx = int(text[3])
142
+ [r, g, b] = text[9:].split("/")
143
+ palette[idx] = Color(f"#{r[0:2]}{g[0:2]}{b[0:2]}")
144
+ elif seq.endswith(b"c"):
145
+ got_da_response = True
146
+ else:
147
+ # Timeout, terminal didn't respond
148
+ break
149
+ except Exception:
150
+ logger.debug("Error getting term colors", exc_info=True)
151
+ # Any exception at all, just use default colors
152
+ pass
153
+
154
+ return (fg, bg, palette)
155
+
156
+
157
+ def color_component_to_8bit_cube(value: int) -> tuple[int, int]:
158
+ # Based on https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit
159
+
160
+ if value < 48:
161
+ value = 0
162
+ idx = 0
163
+ elif value < 115:
164
+ value = 0x5F
165
+ idx = 1
166
+ elif value < 155:
167
+ value = 0x87
168
+ idx = 2
169
+ elif value < 195:
170
+ value = 0xAF
171
+ idx = 3
172
+ elif value < 235:
173
+ value = 0xD7
174
+ idx = 4
175
+ else:
176
+ value = 0xFF
177
+ idx = 5
178
+
179
+ return (value, idx)
180
+
181
+
182
+ def closest_8bit_color_index(c: Color) -> int:
183
+ (quantized_red, red_idx) = color_component_to_8bit_cube(round(c.red * 255))
184
+ (quantized_green, green_idx) = color_component_to_8bit_cube(round(c.green * 255))
185
+ (quantized_blue, blue_idx) = color_component_to_8bit_cube(round(c.blue * 255))
186
+ quantized = Color(
187
+ rgb=(quantized_red / 255, quantized_green / 255, quantized_blue / 255)
188
+ )
189
+ quantized_error = color_distance(c, quantized)
190
+
191
+ gray = Color(hsl=(c.hue, 0, c.luminance))
192
+ gray_idx = round(max(gray.red * 255 - 8, 0) / 10)
193
+ quantized_gray_value = 8 + 10 * gray_idx
194
+ quantized_gray = Color(
195
+ rgb=(
196
+ quantized_gray_value / 255,
197
+ quantized_gray_value / 255,
198
+ quantized_gray_value / 255,
199
+ )
200
+ )
201
+ quantized_gray_error = color_distance(c, quantized_gray)
202
+
203
+ if quantized_error < quantized_gray_error:
204
+ # 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
205
+ idx = 16 + 36 * red_idx + 6 * green_idx + blue_idx
206
+ else:
207
+ idx = 232 + gray_idx
208
+
209
+ return idx
210
+
211
+
212
+ def fg_color_seq(c: int | Color) -> str:
213
+ if isinstance(c, int):
214
+ return f"\x1b[{30 + c}m"
215
+ elif TRUE_COLOR:
216
+ (r, g, b) = c.rgb
217
+ r = round(r * 255)
218
+ g = round(g * 255)
219
+ b = round(b * 255)
220
+ return f"\x1b[38;2;{r};{g};{b}m"
221
+ else:
222
+ idx = closest_8bit_color_index(c)
223
+ return f"\x1b[38;5;{idx}m"
224
+
225
+
226
+ def default_fg_color_seq() -> str:
227
+ return "\x1b[39m"
228
+
229
+
230
+ def bg_color_seq(c: int | Color) -> str:
231
+ if isinstance(c, int):
232
+ return f"\x1b[{40 + c}m"
233
+ elif TRUE_COLOR:
234
+ (r, g, b) = c.rgb
235
+ r = round(r * 255)
236
+ g = round(g * 255)
237
+ b = round(b * 255)
238
+ return f"\x1b[48;2;{r};{g};{b}m"
239
+ else:
240
+ idx = closest_8bit_color_index(c)
241
+ return f"\x1b[48;5;{idx}m"
242
+
243
+
244
+ def get_theme(use_default_colors: bool) -> Theme:
245
+ (fg, bg, palette) = get_term_colors(use_default_colors)
246
+ return Theme(fg, bg, palette)
@@ -0,0 +1,111 @@
1
+ from collections.abc import Callable, Sequence
2
+ from gettext import gettext
3
+ from typing import Any
4
+
5
+ import click
6
+ import questionary
7
+
8
+ from exponent.core.types.generated.strategy_info import StrategyInfo
9
+
10
+
11
+ class AutoCompleteOption(click.Option):
12
+ prompt: str
13
+
14
+ def __init__(
15
+ self,
16
+ param_decls: Sequence[str] | None = None,
17
+ prompt: bool | str = True,
18
+ choices: list[str] | None = None,
19
+ **kwargs: Any,
20
+ ):
21
+ super().__init__(param_decls, prompt=prompt, **kwargs)
22
+ if isinstance(self.type, click.Choice):
23
+ self.choices = self.type.choices
24
+ else:
25
+ self.choices = choices or []
26
+
27
+ def prompt_for_value(self, ctx: click.core.Context) -> Any:
28
+ return questionary.autocomplete(
29
+ self.prompt,
30
+ list(self.choices),
31
+ style=questionary.Style(
32
+ [
33
+ ("question", "bold"), # question text
34
+ (
35
+ "answer",
36
+ "fg:#33ccff bold",
37
+ ), # submitted answer text behind the question
38
+ (
39
+ "answer",
40
+ "bg:#000066",
41
+ ), # submitted answer text behind the question
42
+ ]
43
+ ),
44
+ ).unsafe_ask()
45
+
46
+
47
+ class StrategyChoice(click.Choice[str]):
48
+ def __init__(self, choices: Sequence[StrategyInfo]) -> None:
49
+ self.strategy_choices = choices
50
+ self.choices = [strategy.strategy_name.value for strategy in choices]
51
+ self.case_sensitive = True
52
+
53
+
54
+ class StrategyOption(AutoCompleteOption):
55
+ def __init__(self, *args: Any, type: StrategyChoice, **kwargs: Any):
56
+ super().__init__(*args, type=type, **kwargs)
57
+ self.default = self.default_choice(type.strategy_choices)
58
+ self.strategy_choices = type.strategy_choices
59
+
60
+ def _format_strategy_info(
61
+ self, strategy_info: StrategyInfo, formatter: click.HelpFormatter
62
+ ) -> None:
63
+ row = (strategy_info.strategy_name.value, strategy_info.display_name)
64
+ formatter.write_dl([row])
65
+ with formatter.indentation():
66
+ formatter.write_text(strategy_info.description)
67
+
68
+ def help_extra_hook(self, formatter: click.HelpFormatter) -> None:
69
+ with formatter.section("Strategies"):
70
+ for strategy_info in self.strategy_choices:
71
+ formatter.write_paragraph()
72
+ self._format_strategy_info(strategy_info, formatter)
73
+
74
+ @staticmethod
75
+ def default_choice(choices: Sequence[StrategyInfo]) -> str:
76
+ return min(choices, key=lambda x: x.display_order).strategy_name.value
77
+
78
+
79
+ class ExponentCommand(click.Command):
80
+ def format_options(
81
+ self, ctx: click.Context, formatter: click.HelpFormatter
82
+ ) -> None:
83
+ """Writes all the options into the formatter if they exist."""
84
+ opts = []
85
+ for param in self.get_params(ctx):
86
+ rv = param.get_help_record(ctx)
87
+ hook = getattr(param, "help_extra_hook", None)
88
+ if rv is not None:
89
+ opts.append((rv, hook))
90
+
91
+ if not opts:
92
+ return
93
+
94
+ with formatter.section(gettext("Options")):
95
+ for opt, hook in opts:
96
+ formatter.write_dl([opt])
97
+ if hook is not None:
98
+ hook(formatter)
99
+ formatter.write_paragraph()
100
+
101
+
102
+ class ExponentGroup(click.Group):
103
+ command_class = ExponentCommand
104
+ group_class = type
105
+
106
+
107
+ def exponent_cli_group(
108
+ name: str | None = None,
109
+ **attrs: Any,
110
+ ) -> Callable[[Callable[..., Any]], ExponentGroup]:
111
+ return click.command(name, ExponentGroup, **attrs)
@@ -0,0 +1,29 @@
1
+ import click
2
+
3
+ from exponent.commands.types import exponent_cli_group
4
+ from exponent.utils.version import check_exponent_version, upgrade_exponent
5
+
6
+
7
+ @exponent_cli_group()
8
+ def upgrade_cli() -> None:
9
+ """Manage Exponent version upgrades."""
10
+ pass
11
+
12
+
13
+ @upgrade_cli.command()
14
+ @click.option(
15
+ "--force",
16
+ is_flag=True,
17
+ help="Upgrade without prompting for confirmation, if a new version is available.",
18
+ )
19
+ def upgrade(force: bool = False) -> None:
20
+ """Upgrade Exponent to the latest version."""
21
+ if result := check_exponent_version():
22
+ installed_version, latest_version = result
23
+ upgrade_exponent(
24
+ current_version=installed_version,
25
+ new_version=latest_version,
26
+ force=force,
27
+ )
28
+ else:
29
+ click.echo("Exponent is already up to date.")
@@ -0,0 +1,236 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import threading
5
+ import time
6
+ import webbrowser
7
+
8
+ import click
9
+
10
+ from exponent.core.config import Environment, Settings
11
+ from exponent.utils.version import get_installed_version
12
+
13
+
14
+ def print_editable_install_forced_prod_warning(settings: Settings) -> None:
15
+ click.secho(
16
+ "Detected local editable install, but this command only works against prod.",
17
+ fg="red",
18
+ bold=True,
19
+ )
20
+ click.secho("Using prod settings:", fg="red", bold=True)
21
+ click.secho("- base_url=", fg="yellow", bold=True, nl=False)
22
+ click.secho(f"{settings.base_url}", fg=(100, 200, 255), bold=False)
23
+ click.secho("- base_api_url=", fg="yellow", bold=True, nl=False)
24
+ click.secho(f"{settings.get_base_api_url()}", fg=(100, 200, 255), bold=False)
25
+ click.secho()
26
+
27
+
28
+ def print_editable_install_warning(settings: Settings) -> None:
29
+ click.secho(
30
+ "Detected local editable install, using local URLs", fg="yellow", bold=True
31
+ )
32
+ click.secho("- base_url=", fg="yellow", bold=True, nl=False)
33
+ click.secho(f"{settings.base_url}", fg=(100, 200, 255), bold=False)
34
+ click.secho("- base_api_url=", fg="yellow", bold=True, nl=False)
35
+ click.secho(f"{settings.get_base_api_url()}", fg=(100, 200, 255), bold=False)
36
+ click.secho()
37
+
38
+
39
+ def print_exponent_message(base_url: str, chat_uuid: str) -> None:
40
+ version = get_installed_version()
41
+ shell = os.environ.get("SHELL")
42
+
43
+ click.echo()
44
+ click.secho(f"△ Indent v{version}", fg=(180, 150, 255), bold=True)
45
+ click.echo()
46
+ click.echo(
47
+ " - Link: " + click.style(f"{base_url}/chats/{chat_uuid}", fg=(100, 200, 255))
48
+ )
49
+
50
+ if shell is not None:
51
+ click.echo(f" - Shell: {shell}")
52
+
53
+
54
+ def is_indent_app_installed() -> bool:
55
+ if sys.platform == "darwin": # macOS
56
+ return os.path.exists("/Applications/Indent.app")
57
+
58
+ # TODO: Add support for Windows and Linux
59
+ return False
60
+
61
+
62
+ def launch_exponent_browser(
63
+ environment: Environment, base_url: str, chat_uuid: str
64
+ ) -> None:
65
+ if is_indent_app_installed() and environment == Environment.production:
66
+ url = f"exponent://chats/{chat_uuid}"
67
+ else:
68
+ url = f"{base_url}/chats/{chat_uuid}"
69
+ webbrowser.open(url)
70
+
71
+
72
+ def start_background_event_loop() -> asyncio.AbstractEventLoop:
73
+ def run_event_loop(loop: asyncio.AbstractEventLoop) -> None:
74
+ asyncio.set_event_loop(loop)
75
+ loop.run_forever()
76
+
77
+ loop = asyncio.new_event_loop()
78
+ thread = threading.Thread(target=run_event_loop, args=(loop,), daemon=True)
79
+ thread.start()
80
+ return loop
81
+
82
+
83
+ def read_input(prompt: str) -> str:
84
+ sys.stdout.write(prompt)
85
+ sys.stdout.flush()
86
+ return sys.stdin.readline()
87
+
88
+
89
+ def ask_for_quit_confirmation(program_name: str = "Exponent") -> bool:
90
+ while True:
91
+ try:
92
+ answer = (
93
+ input(f"Do you want to quit {program_name}? [y/\x1b[1mN\x1b[0m]")
94
+ .strip()
95
+ .lower()
96
+ )
97
+ if answer in {"y", "yes"}:
98
+ return True
99
+ elif answer in {"n", "no", ""}:
100
+ return False
101
+ except KeyboardInterrupt:
102
+ print()
103
+ return True
104
+ except EOFError:
105
+ print()
106
+ return True
107
+
108
+
109
+ class Spinner:
110
+ def __init__(self, text: str) -> None:
111
+ self.text = text
112
+ self.task: asyncio.Task[None] | None = None
113
+ self.base_time = time.time()
114
+ self.fg_color: tuple[int, int, int] | None = None
115
+ self.bold = False
116
+ self.animation_chars = "⣷⣯⣟⡿⢿⣻⣽⣾"
117
+ self.animation_speed = 10
118
+
119
+ def show(self) -> None:
120
+ if self.task is not None:
121
+ return
122
+
123
+ async def spinner(base_time: float) -> None:
124
+ color_start = ""
125
+ if self.fg_color:
126
+ if isinstance(self.fg_color, tuple) and len(self.fg_color) == 3:
127
+ r, g, b = self.fg_color
128
+ color_start = f"\x1b[38;2;{r};{g};{b}m"
129
+ elif isinstance(self.fg_color, int):
130
+ color_start = f"\x1b[{30 + self.fg_color}m"
131
+
132
+ bold_start = "\x1b[1m" if self.bold else ""
133
+ style_start = f"{color_start}{bold_start}"
134
+ style_end = "\x1b[0m"
135
+
136
+ while True:
137
+ t = time.time() - base_time
138
+ i = round(t * self.animation_speed) % len(self.animation_chars)
139
+ print(
140
+ f"\r{style_start}{self.animation_chars[i]} {self.text}{style_end}",
141
+ end="",
142
+ )
143
+ await asyncio.sleep(0.1)
144
+
145
+ self.task = asyncio.get_event_loop().create_task(spinner(self.base_time))
146
+
147
+ def hide(self) -> None:
148
+ if self.task is None:
149
+ return
150
+
151
+ self.task.cancel()
152
+ self.task = None
153
+ print("\r\x1b[0m\x1b[2K", end="")
154
+ sys.stdout.flush()
155
+
156
+
157
+ class ThinkingSpinner(Spinner):
158
+ def __init__(
159
+ self,
160
+ fg_color: tuple[int, int, int] | None = None,
161
+ text: str = "Exponent is thinking",
162
+ ) -> None:
163
+ super().__init__(text)
164
+ self.fg_color = fg_color
165
+ self.bold = True
166
+ self.animation_chars = "⣾⣽⣻⢿⡿⣟⣯⣷" # More subtle animation
167
+ self.animation_speed = 8 # Medium speed animation
168
+ self.start_time = time.time() # Track when thinking started
169
+
170
+ def show(self) -> None:
171
+ if self.task is not None:
172
+ return
173
+
174
+ async def spinner(base_time: float) -> None:
175
+ color_start = ""
176
+ if self.fg_color:
177
+ if isinstance(self.fg_color, tuple) and len(self.fg_color) == 3:
178
+ r, g, b = self.fg_color
179
+ color_start = f"\x1b[38;2;{r};{g};{b}m"
180
+ elif isinstance(self.fg_color, int):
181
+ color_start = f"\x1b[{30 + self.fg_color}m"
182
+
183
+ bold_start = "\x1b[1m" if self.bold else ""
184
+ style_start = f"{color_start}{bold_start}"
185
+ style_end = "\x1b[0m"
186
+
187
+ while True:
188
+ t = time.time() - base_time
189
+ i = round(t * self.animation_speed) % len(self.animation_chars)
190
+
191
+ # Calculate elapsed seconds
192
+ elapsed = time.time() - self.start_time
193
+ if elapsed < 60:
194
+ timer = f"{int(elapsed)}s"
195
+ else:
196
+ mins = int(elapsed // 60)
197
+ secs = int(elapsed % 60)
198
+ timer = f"{mins}m {secs}s"
199
+
200
+ print(
201
+ f"\r{style_start}{self.animation_chars[i]} {self.text} ({timer}){style_end}",
202
+ end="",
203
+ )
204
+ await asyncio.sleep(0.1)
205
+
206
+ self.task = asyncio.get_event_loop().create_task(spinner(self.base_time))
207
+
208
+
209
+ class ConnectionTracker:
210
+ def __init__(self) -> None:
211
+ self.connected = True
212
+ self.queue: asyncio.Queue[bool] = asyncio.Queue()
213
+
214
+ def is_connected(self) -> bool:
215
+ while True:
216
+ try:
217
+ self.connected = self.queue.get_nowait()
218
+ except asyncio.QueueEmpty:
219
+ break
220
+
221
+ return self.connected
222
+
223
+ async def wait_for_reconnection(self) -> None:
224
+ if not self.is_connected():
225
+ assert await self.queue.get()
226
+ self.connected = True
227
+
228
+ async def set_connected(self, connected: bool) -> None:
229
+ await self.queue.put(connected)
230
+
231
+ async def next_change(self) -> bool:
232
+ return await self.queue.get()
233
+
234
+
235
+ def get_short_git_commit_hash(commit_hash: str) -> str:
236
+ return commit_hash[:8]