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.
- exponent/__init__.py +1 -0
- exponent/cli.py +112 -0
- exponent/commands/cloud_commands.py +85 -0
- exponent/commands/common.py +434 -0
- exponent/commands/config_commands.py +581 -0
- exponent/commands/github_app_commands.py +211 -0
- exponent/commands/listen_commands.py +96 -0
- exponent/commands/run_commands.py +208 -0
- exponent/commands/settings.py +56 -0
- exponent/commands/shell_commands.py +2840 -0
- exponent/commands/theme.py +246 -0
- exponent/commands/types.py +111 -0
- exponent/commands/upgrade.py +29 -0
- exponent/commands/utils.py +236 -0
- exponent/core/config.py +180 -0
- exponent/core/graphql/__init__.py +0 -0
- exponent/core/graphql/client.py +59 -0
- exponent/core/graphql/cloud_config_queries.py +77 -0
- exponent/core/graphql/get_chats_query.py +47 -0
- exponent/core/graphql/github_config_queries.py +56 -0
- exponent/core/graphql/mutations.py +75 -0
- exponent/core/graphql/queries.py +110 -0
- exponent/core/graphql/subscriptions.py +452 -0
- exponent/core/remote_execution/checkpoints.py +212 -0
- exponent/core/remote_execution/cli_rpc_types.py +214 -0
- exponent/core/remote_execution/client.py +545 -0
- exponent/core/remote_execution/code_execution.py +58 -0
- exponent/core/remote_execution/command_execution.py +105 -0
- exponent/core/remote_execution/error_info.py +45 -0
- exponent/core/remote_execution/exceptions.py +10 -0
- exponent/core/remote_execution/file_write.py +410 -0
- exponent/core/remote_execution/files.py +415 -0
- exponent/core/remote_execution/git.py +268 -0
- exponent/core/remote_execution/languages/python_execution.py +239 -0
- exponent/core/remote_execution/languages/shell_streaming.py +221 -0
- exponent/core/remote_execution/languages/types.py +20 -0
- exponent/core/remote_execution/session.py +128 -0
- exponent/core/remote_execution/system_context.py +54 -0
- exponent/core/remote_execution/tool_execution.py +289 -0
- exponent/core/remote_execution/truncation.py +284 -0
- exponent/core/remote_execution/types.py +670 -0
- exponent/core/remote_execution/utils.py +600 -0
- exponent/core/types/__init__.py +0 -0
- exponent/core/types/command_data.py +206 -0
- exponent/core/types/event_types.py +89 -0
- exponent/core/types/generated/__init__.py +0 -0
- exponent/core/types/generated/strategy_info.py +225 -0
- exponent/migration-docs/login.md +112 -0
- exponent/py.typed +4 -0
- exponent/utils/__init__.py +0 -0
- exponent/utils/colors.py +92 -0
- exponent/utils/version.py +289 -0
- indent-0.0.8.dist-info/METADATA +36 -0
- indent-0.0.8.dist-info/RECORD +56 -0
- indent-0.0.8.dist-info/WHEEL +4 -0
- 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]
|