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.
Files changed (43) hide show
  1. delos_cli/__init__.py +3 -0
  2. delos_cli/agent/__init__.py +34 -0
  3. delos_cli/agent/session.py +111 -0
  4. delos_cli/agent/tools.py +131 -0
  5. delos_cli/agent/transport.py +102 -0
  6. delos_cli/apps/__init__.py +6 -0
  7. delos_cli/apps/base.py +101 -0
  8. delos_cli/apps/chat/__init__.py +5 -0
  9. delos_cli/apps/chat/app.py +149 -0
  10. delos_cli/apps/chat/commands.py +17 -0
  11. delos_cli/apps/chat/render.py +188 -0
  12. delos_cli/apps/chat/replay.py +108 -0
  13. delos_cli/auth/__init__.py +24 -0
  14. delos_cli/auth/config.py +282 -0
  15. delos_cli/auth/mfa.py +120 -0
  16. delos_cli/auth/oauth.py +336 -0
  17. delos_cli/auth/token_manager.py +136 -0
  18. delos_cli/commands/__init__.py +10 -0
  19. delos_cli/commands/base.py +54 -0
  20. delos_cli/commands/builtin.py +160 -0
  21. delos_cli/ctx.py +65 -0
  22. delos_cli/loop.py +19 -0
  23. delos_cli/main.py +230 -0
  24. delos_cli/state.py +28 -0
  25. delos_cli/tools/__init__.py +20 -0
  26. delos_cli/tools/edit_content.py +193 -0
  27. delos_cli/tools/run_shell.py +150 -0
  28. delos_cli/tools/write_content.py +120 -0
  29. delos_cli/transport/__init__.py +24 -0
  30. delos_cli/transport/chats.py +235 -0
  31. delos_cli/transport/client.py +321 -0
  32. delos_cli/transport/models.py +19 -0
  33. delos_cli/ui/__init__.py +6 -0
  34. delos_cli/ui/chat_picker.py +151 -0
  35. delos_cli/ui/completer.py +68 -0
  36. delos_cli/ui/lexer.py +62 -0
  37. delos_cli/ui/output.py +180 -0
  38. delos_cli/ui/repl.py +679 -0
  39. delos_cli/ui/style.py +24 -0
  40. delos_cli-0.1.0.dist-info/METADATA +104 -0
  41. delos_cli-0.1.0.dist-info/RECORD +43 -0
  42. delos_cli-0.1.0.dist-info/WHEEL +4 -0
  43. 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()