delos-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.
- delos_cli/__init__.py +3 -0
- delos_cli/agent/__init__.py +34 -0
- delos_cli/agent/session.py +111 -0
- delos_cli/agent/tools.py +131 -0
- delos_cli/agent/transport.py +102 -0
- delos_cli/apps/__init__.py +6 -0
- delos_cli/apps/base.py +101 -0
- delos_cli/apps/chat/__init__.py +5 -0
- delos_cli/apps/chat/app.py +149 -0
- delos_cli/apps/chat/commands.py +17 -0
- delos_cli/apps/chat/render.py +188 -0
- delos_cli/apps/chat/replay.py +108 -0
- delos_cli/auth/__init__.py +24 -0
- delos_cli/auth/config.py +282 -0
- delos_cli/auth/mfa.py +120 -0
- delos_cli/auth/oauth.py +336 -0
- delos_cli/auth/token_manager.py +136 -0
- delos_cli/commands/__init__.py +10 -0
- delos_cli/commands/base.py +54 -0
- delos_cli/commands/builtin.py +160 -0
- delos_cli/ctx.py +65 -0
- delos_cli/loop.py +19 -0
- delos_cli/main.py +230 -0
- delos_cli/state.py +28 -0
- delos_cli/tools/__init__.py +20 -0
- delos_cli/tools/edit_content.py +193 -0
- delos_cli/tools/run_shell.py +150 -0
- delos_cli/tools/write_content.py +120 -0
- delos_cli/transport/__init__.py +24 -0
- delos_cli/transport/chats.py +235 -0
- delos_cli/transport/client.py +321 -0
- delos_cli/transport/models.py +19 -0
- delos_cli/ui/__init__.py +6 -0
- delos_cli/ui/chat_picker.py +151 -0
- delos_cli/ui/completer.py +68 -0
- delos_cli/ui/lexer.py +62 -0
- delos_cli/ui/output.py +180 -0
- delos_cli/ui/repl.py +679 -0
- delos_cli/ui/style.py +24 -0
- delos_cli-0.1.0.dist-info/METADATA +104 -0
- delos_cli-0.1.0.dist-info/RECORD +43 -0
- delos_cli-0.1.0.dist-info/WHEEL +4 -0
- delos_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Startup picker: resume a recent chat, or start a new one.
|
|
2
|
+
|
|
3
|
+
Shown once on REPL start if the user has recent chats. Flat list with
|
|
4
|
+
time-bucket separators (Today / Yesterday / This week / Older) so the
|
|
5
|
+
user can scan without paging. Keyboard-only: ↑↓ to move, ⏎ to pick,
|
|
6
|
+
esc to quit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import questionary
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from delos_cli.transport.chats import ChatListItem
|
|
18
|
+
|
|
19
|
+
# Sentinel value questionary returns from the "new chat" choice — no chat to resume.
|
|
20
|
+
NEW_CHAT = "__new__"
|
|
21
|
+
|
|
22
|
+
# How wide the title column is padded to. Anything longer is truncated with …
|
|
23
|
+
_TITLE_WIDTH = 44
|
|
24
|
+
|
|
25
|
+
_PICKER_STYLE = questionary.Style(
|
|
26
|
+
[
|
|
27
|
+
("qmark", "fg:ansicyan"),
|
|
28
|
+
("question", "bold"),
|
|
29
|
+
("pointer", "fg:ansicyan bold"),
|
|
30
|
+
("highlighted", "fg:ansicyan bold"),
|
|
31
|
+
("selected", "fg:ansicyan"),
|
|
32
|
+
("answer", "fg:ansicyan"),
|
|
33
|
+
("separator", "fg:#6c6c6c"),
|
|
34
|
+
("instruction", "fg:#6c6c6c"),
|
|
35
|
+
],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def pick_conversation(chats: list[ChatListItem]) -> str | None:
|
|
40
|
+
"""Ask the user to pick a chat to resume, or start fresh.
|
|
41
|
+
|
|
42
|
+
Returns the chosen ``chat_id`` string, or ``None`` for "new chat"
|
|
43
|
+
(also returned when the user presses esc / ctrl-c — callers should
|
|
44
|
+
treat ``None`` as cancellation-equivalent).
|
|
45
|
+
|
|
46
|
+
Async because the caller already runs inside an :func:`asyncio.run`
|
|
47
|
+
loop; using ``.ask_async()`` avoids trying to start a nested loop.
|
|
48
|
+
"""
|
|
49
|
+
if not chats:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
now = datetime.now(UTC)
|
|
53
|
+
choices: list[questionary.Choice | questionary.Separator] = [
|
|
54
|
+
questionary.Choice(title="✱ Start a new chat", value=NEW_CHAT),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
for bucket_label, items in _group_by_bucket(chats, now):
|
|
58
|
+
if not items:
|
|
59
|
+
continue
|
|
60
|
+
choices.append(questionary.Separator(f" {bucket_label}"))
|
|
61
|
+
choices.extend(_choice_for(chat, now) for chat in items)
|
|
62
|
+
|
|
63
|
+
answer = await questionary.select(
|
|
64
|
+
"Pick a conversation",
|
|
65
|
+
choices=choices,
|
|
66
|
+
use_shortcuts=False,
|
|
67
|
+
use_arrow_keys=True,
|
|
68
|
+
qmark="›",
|
|
69
|
+
instruction="(↑↓ to move · ⏎ to open · esc to quit)",
|
|
70
|
+
style=_PICKER_STYLE,
|
|
71
|
+
).ask_async()
|
|
72
|
+
|
|
73
|
+
if answer is None or answer == NEW_CHAT:
|
|
74
|
+
return None
|
|
75
|
+
return str(answer)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _choice_for(chat: ChatListItem, now: datetime) -> questionary.Choice:
|
|
79
|
+
title = chat.name or "(untitled)"
|
|
80
|
+
display_title = _truncate(title, _TITLE_WIDTH).ljust(_TITLE_WIDTH)
|
|
81
|
+
age = _relative_age(now - chat.updated_at)
|
|
82
|
+
count = f"{chat.message_count} msg" + ("s" if chat.message_count != 1 else "")
|
|
83
|
+
# One visual line: title padded left-aligned, then metadata in the same row.
|
|
84
|
+
line = f" {display_title} {age} · {count}"
|
|
85
|
+
return questionary.Choice(title=line, value=chat.id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _group_by_bucket(
|
|
89
|
+
chats: list[ChatListItem],
|
|
90
|
+
now: datetime,
|
|
91
|
+
) -> list[tuple[str, list[ChatListItem]]]:
|
|
92
|
+
today: list[ChatListItem] = []
|
|
93
|
+
yesterday: list[ChatListItem] = []
|
|
94
|
+
this_week: list[ChatListItem] = []
|
|
95
|
+
older: list[ChatListItem] = []
|
|
96
|
+
|
|
97
|
+
for c in chats:
|
|
98
|
+
age_days = (now - c.updated_at).days
|
|
99
|
+
if age_days <= 0:
|
|
100
|
+
today.append(c)
|
|
101
|
+
elif age_days == 1:
|
|
102
|
+
yesterday.append(c)
|
|
103
|
+
elif age_days < _DAYS_PER_WEEK:
|
|
104
|
+
this_week.append(c)
|
|
105
|
+
else:
|
|
106
|
+
older.append(c)
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
("Today", today),
|
|
110
|
+
("Yesterday", yesterday),
|
|
111
|
+
("This week", this_week),
|
|
112
|
+
("Older", older),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_MINUTE_S = 60
|
|
117
|
+
_HOUR_S = 60 * _MINUTE_S
|
|
118
|
+
_DAY_S = 24 * _HOUR_S
|
|
119
|
+
_DAYS_PER_WEEK = 7
|
|
120
|
+
_WEEKS_CUTOFF = 5 # beyond ~a month, switch to "mo ago"
|
|
121
|
+
_DAYS_PER_MONTH = 30
|
|
122
|
+
_MONTHS_PER_YEAR = 12
|
|
123
|
+
_DAYS_PER_YEAR = 365
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _relative_age(delta: timedelta) -> str:
|
|
127
|
+
s = max(int(delta.total_seconds()), 0)
|
|
128
|
+
if s < _MINUTE_S:
|
|
129
|
+
return "just now"
|
|
130
|
+
if s < _HOUR_S:
|
|
131
|
+
return f"{s // _MINUTE_S}m ago"
|
|
132
|
+
if s < _DAY_S:
|
|
133
|
+
return f"{s // _HOUR_S}h ago"
|
|
134
|
+
d = s // _DAY_S
|
|
135
|
+
if d == 1:
|
|
136
|
+
return "yesterday"
|
|
137
|
+
if d < _DAYS_PER_WEEK:
|
|
138
|
+
return f"{d}d ago"
|
|
139
|
+
weeks = d // _DAYS_PER_WEEK
|
|
140
|
+
if weeks < _WEEKS_CUTOFF:
|
|
141
|
+
return f"{weeks}w ago"
|
|
142
|
+
months = d // _DAYS_PER_MONTH
|
|
143
|
+
if months < _MONTHS_PER_YEAR:
|
|
144
|
+
return f"{months}mo ago"
|
|
145
|
+
return f"{d // _DAYS_PER_YEAR}y ago"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _truncate(s: str, width: int) -> str:
|
|
149
|
+
if len(s) <= width:
|
|
150
|
+
return s
|
|
151
|
+
return s[: width - 1] + "…"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Slash-command completer.
|
|
2
|
+
|
|
3
|
+
At the start of a line: complete ``/<command>`` against the merged
|
|
4
|
+
``global + app`` registry. After the command has been typed (space follows):
|
|
5
|
+
delegate to the matched command's own ``complete`` callback, if it defines
|
|
6
|
+
one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
14
|
+
|
|
15
|
+
from delos_cli.commands import GLOBAL_COMMANDS
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from prompt_toolkit.completion import CompleteEvent
|
|
21
|
+
from prompt_toolkit.document import Document
|
|
22
|
+
|
|
23
|
+
from delos_cli.commands.base import CommandSpec
|
|
24
|
+
from delos_cli.ctx import Ctx
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommandCompleter(Completer):
|
|
28
|
+
"""Reads the live command registry from ``ctx.app`` on every keystroke."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, ctx: Ctx) -> None:
|
|
31
|
+
"""Hold a reference to ``ctx`` so completions see live app state."""
|
|
32
|
+
self._ctx = ctx
|
|
33
|
+
|
|
34
|
+
def get_completions(
|
|
35
|
+
self, document: Document, complete_event: CompleteEvent,
|
|
36
|
+
) -> Iterable[Completion]:
|
|
37
|
+
"""Yield completions for the current buffer position."""
|
|
38
|
+
_ = complete_event
|
|
39
|
+
text = document.text_before_cursor
|
|
40
|
+
if "\n" in text or not text.startswith("/"):
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if " " in text:
|
|
44
|
+
head, _, rest = text.partition(" ")
|
|
45
|
+
spec = self._all_commands().get(head)
|
|
46
|
+
if spec is None or spec.complete is None:
|
|
47
|
+
return
|
|
48
|
+
needle = rest.lower()
|
|
49
|
+
for suggestion in spec.complete(self._ctx, rest):
|
|
50
|
+
if needle in suggestion.lower():
|
|
51
|
+
yield Completion(suggestion, start_position=-len(rest))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
seen: set[str] = set()
|
|
55
|
+
for spec in self._all_commands().values():
|
|
56
|
+
if spec.name in seen or not spec.name.startswith(text):
|
|
57
|
+
continue
|
|
58
|
+
seen.add(spec.name)
|
|
59
|
+
yield Completion(
|
|
60
|
+
spec.name,
|
|
61
|
+
start_position=-len(text),
|
|
62
|
+
display_meta=spec.summary,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _all_commands(self) -> dict[str, CommandSpec]:
|
|
66
|
+
merged: dict[str, CommandSpec] = dict(GLOBAL_COMMANDS)
|
|
67
|
+
merged.update(self._ctx.app.commands)
|
|
68
|
+
return merged
|
delos_cli/ui/lexer.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Input lexer: colors slash commands, @mentions, URLs, and code fences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.lexers import Lexer
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
|
|
13
|
+
from prompt_toolkit.document import Document
|
|
14
|
+
from prompt_toolkit.formatted_text.base import StyleAndTextTuples
|
|
15
|
+
|
|
16
|
+
_URL_RE = re.compile(r"https?://\S+")
|
|
17
|
+
_MENTION_RE = re.compile(r"@[\w./-]+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChatLexer(Lexer):
|
|
21
|
+
"""Tiny line-aware lexer driven by a few regexes."""
|
|
22
|
+
|
|
23
|
+
def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]:
|
|
24
|
+
"""Return a per-line lexer function over ``document``'s current lines."""
|
|
25
|
+
_ = self
|
|
26
|
+
lines = document.lines
|
|
27
|
+
|
|
28
|
+
def get_line(lineno: int) -> StyleAndTextTuples:
|
|
29
|
+
line = lines[lineno]
|
|
30
|
+
if lineno == 0 and line.startswith("/"):
|
|
31
|
+
head, sep, rest = line.partition(" ")
|
|
32
|
+
out: StyleAndTextTuples = [("class:repl.cmd", head)]
|
|
33
|
+
if sep:
|
|
34
|
+
out.append(("", sep + rest))
|
|
35
|
+
return out
|
|
36
|
+
if line.startswith("```"):
|
|
37
|
+
return [("class:repl.fence", line)]
|
|
38
|
+
return list(_tokenize_line(line))
|
|
39
|
+
|
|
40
|
+
return get_line
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tokenize_line(line: str) -> Iterable[tuple[str, str]]:
|
|
44
|
+
i = 0
|
|
45
|
+
for m in _URL_RE.finditer(line):
|
|
46
|
+
if m.start() > i:
|
|
47
|
+
yield from _tokenize_mentions(line[i : m.start()])
|
|
48
|
+
yield ("class:repl.url", m.group())
|
|
49
|
+
i = m.end()
|
|
50
|
+
if i < len(line):
|
|
51
|
+
yield from _tokenize_mentions(line[i:])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _tokenize_mentions(text: str) -> Iterable[tuple[str, str]]:
|
|
55
|
+
i = 0
|
|
56
|
+
for m in _MENTION_RE.finditer(text):
|
|
57
|
+
if m.start() > i:
|
|
58
|
+
yield ("", text[i : m.start()])
|
|
59
|
+
yield ("class:repl.mention", m.group())
|
|
60
|
+
i = m.end()
|
|
61
|
+
if i < len(text):
|
|
62
|
+
yield ("", text[i:])
|
delos_cli/ui/output.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Output buffer for the multi-area REPL layout.
|
|
2
|
+
|
|
3
|
+
Bridges the v6 event renderer (which produces Rich renderables) to the
|
|
4
|
+
prompt_toolkit Application (which renders ANSI text inside a Window).
|
|
5
|
+
Owns:
|
|
6
|
+
|
|
7
|
+
- ``history`` — a list of finalized blocks (tool calls, completed
|
|
8
|
+
assistant turns, banner lines, command output, …). Pre-rendered as
|
|
9
|
+
ANSI text to keep redraws cheap.
|
|
10
|
+
- ``live`` — an optional in-flight block that re-renders on every
|
|
11
|
+
``text-delta`` during streaming. When :meth:`commit_live` is called
|
|
12
|
+
(text-end), it joins history and the slot becomes free again.
|
|
13
|
+
|
|
14
|
+
Each :meth:`update_live` / :meth:`append_block` / :meth:`print` call
|
|
15
|
+
invalidates the attached :class:`Application` so its redraw loop picks
|
|
16
|
+
up the change.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from prompt_toolkit.application import Application
|
|
29
|
+
from rich.console import RenderableType
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_DEFAULT_WIDTH = 100
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _terminal_width() -> int:
|
|
36
|
+
"""Best-effort terminal column count; falls back to 100."""
|
|
37
|
+
try:
|
|
38
|
+
return os.get_terminal_size().columns
|
|
39
|
+
except OSError:
|
|
40
|
+
return _DEFAULT_WIDTH
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutputBuffer:
|
|
44
|
+
"""Append-only output buffer with a single in-flight live slot.
|
|
45
|
+
|
|
46
|
+
Stores rendered ANSI text so the prompt_toolkit redraw can simply
|
|
47
|
+
concatenate strings — no Rich re-render on every keystroke.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, width: int | None = None) -> None:
|
|
51
|
+
"""Build an empty buffer. ``width`` defaults to the terminal width."""
|
|
52
|
+
self._width = width or _terminal_width()
|
|
53
|
+
self._console = self._make_console(self._width)
|
|
54
|
+
self._history: list[str] = []
|
|
55
|
+
self._live: str | None = None
|
|
56
|
+
self._app: Application | None = None
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _make_console(width: int) -> Console:
|
|
60
|
+
return Console(
|
|
61
|
+
width=width,
|
|
62
|
+
force_terminal=True,
|
|
63
|
+
color_system="truecolor",
|
|
64
|
+
soft_wrap=False,
|
|
65
|
+
highlight=False,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def attach(self, app: Application) -> None:
|
|
69
|
+
"""Bind the buffer to a prompt_toolkit application for redraw triggers."""
|
|
70
|
+
self._app = app
|
|
71
|
+
|
|
72
|
+
def set_width(self, width: int) -> None:
|
|
73
|
+
"""Update the rendering width (call on terminal resize)."""
|
|
74
|
+
if width == self._width:
|
|
75
|
+
return
|
|
76
|
+
self._width = width
|
|
77
|
+
self._console = self._make_console(width)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def width(self) -> int:
|
|
81
|
+
"""Current rendering width."""
|
|
82
|
+
return self._width
|
|
83
|
+
|
|
84
|
+
def append_block(self, renderable: RenderableType) -> None:
|
|
85
|
+
"""Render a block and add it to history.
|
|
86
|
+
|
|
87
|
+
Any in-flight live block is committed first so blocks don't
|
|
88
|
+
interleave with streaming text.
|
|
89
|
+
"""
|
|
90
|
+
self._commit_live()
|
|
91
|
+
self._history.append(self._render(renderable))
|
|
92
|
+
self._invalidate()
|
|
93
|
+
|
|
94
|
+
def update_live(self, renderable: RenderableType) -> None:
|
|
95
|
+
"""Replace the live block — called per text-delta during streaming."""
|
|
96
|
+
self._live = self._render(renderable)
|
|
97
|
+
self._invalidate()
|
|
98
|
+
|
|
99
|
+
def commit_live(self) -> None:
|
|
100
|
+
"""Finalize the current live block into history. No-op if none."""
|
|
101
|
+
if self._live is None:
|
|
102
|
+
return
|
|
103
|
+
self._commit_live()
|
|
104
|
+
self._invalidate()
|
|
105
|
+
|
|
106
|
+
def discard_live(self) -> None:
|
|
107
|
+
"""Drop the current live block without committing it to history.
|
|
108
|
+
|
|
109
|
+
Used for ephemeral indicators (e.g. the "thinking" spinner) that
|
|
110
|
+
shouldn't appear in scrollback once the agent emits real output.
|
|
111
|
+
"""
|
|
112
|
+
if self._live is None:
|
|
113
|
+
return
|
|
114
|
+
self._live = None
|
|
115
|
+
self._invalidate()
|
|
116
|
+
|
|
117
|
+
def clear(self) -> None:
|
|
118
|
+
"""Wipe history and any in-flight live block — resets the UI."""
|
|
119
|
+
self._history.clear()
|
|
120
|
+
self._live = None
|
|
121
|
+
self._invalidate()
|
|
122
|
+
|
|
123
|
+
def print(self, *renderables: Any, **kwargs: Any) -> None:
|
|
124
|
+
"""Append an informational block to history.
|
|
125
|
+
|
|
126
|
+
Critically, this does **not** commit the in-flight live block —
|
|
127
|
+
external callers (slash command output, queue / stop confirmations,
|
|
128
|
+
echoed user messages, …) shouldn't disrupt streaming. Their text
|
|
129
|
+
appears in history above the live slot; ``get_ansi`` still puts the
|
|
130
|
+
live last, so the user sees ``[old history] → [message] → [streaming]``.
|
|
131
|
+
|
|
132
|
+
Block-level transitions inside the conversation flow (tool calls,
|
|
133
|
+
finished assistant turns, errors) go through :meth:`append_block`,
|
|
134
|
+
which commits the live first.
|
|
135
|
+
"""
|
|
136
|
+
if not renderables:
|
|
137
|
+
return
|
|
138
|
+
with self._console.capture() as cap:
|
|
139
|
+
self._console.print(*renderables, **kwargs)
|
|
140
|
+
self._history.append(cap.get())
|
|
141
|
+
self._invalidate()
|
|
142
|
+
|
|
143
|
+
def get_ansi(self) -> ANSI:
|
|
144
|
+
"""Return the full output as one ANSI string for prompt_toolkit."""
|
|
145
|
+
parts = list(self._history)
|
|
146
|
+
if self._live is not None:
|
|
147
|
+
parts.append(self._live)
|
|
148
|
+
return ANSI("".join(parts))
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def line_count(self) -> int:
|
|
152
|
+
"""Total source-line count across history + live.
|
|
153
|
+
|
|
154
|
+
Used by the REPL to position an invisible cursor at the bottom of
|
|
155
|
+
the output Window so prompt_toolkit auto-scrolls to follow the
|
|
156
|
+
latest content. Lines are counted in source-line space; wrapping
|
|
157
|
+
is handled by the Window at render time.
|
|
158
|
+
"""
|
|
159
|
+
text = "".join(self._history)
|
|
160
|
+
if self._live is not None:
|
|
161
|
+
text += self._live
|
|
162
|
+
if not text:
|
|
163
|
+
return 0
|
|
164
|
+
# Each "\n" terminates a line, plus one if the buffer doesn't end
|
|
165
|
+
# in a newline (Rich's renders almost always do, but be safe).
|
|
166
|
+
return text.count("\n") + (0 if text.endswith("\n") else 1)
|
|
167
|
+
|
|
168
|
+
def _commit_live(self) -> None:
|
|
169
|
+
if self._live is not None:
|
|
170
|
+
self._history.append(self._live)
|
|
171
|
+
self._live = None
|
|
172
|
+
|
|
173
|
+
def _render(self, renderable: RenderableType) -> str:
|
|
174
|
+
with self._console.capture() as cap:
|
|
175
|
+
self._console.print(renderable)
|
|
176
|
+
return cap.get()
|
|
177
|
+
|
|
178
|
+
def _invalidate(self) -> None:
|
|
179
|
+
if self._app is not None:
|
|
180
|
+
self._app.invalidate()
|